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 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 staged fight's arena and floor height, for the capture mode: the +-- foe's world cell is the far end of the throw and the floor is what a +-- short ball bounces on. nil whenever there is nothing staged, which is +-- one of the gates that sends a throw back to the engine's own toss. +function OverworldBattle.arenaInfo() + if not (session and session.arena and not session.broken) then return nil end + local host = session.arena.map or (session.state and session.state.map) + if not host then return nil end + return session.arena, BattleScene.groundY(host, session.arena) +end + +-- The foe's rendered pic texture, for the capture mode's ring. The mark +-- BattleScene pins is the CELL's ground point, but a species' art sits +-- wherever the artist drew it in the frame -- a bird hovers half a slot +-- above its own feet row -- and a timing ring belongs on the CREATURE, +-- not on its patch of grass. The capture session reads this canvas back +-- once and centres the ring on the art's opaque box. nil on the STADIUM +-- rungs (the foe is a model, no pic is rendered) and before the first +-- textures pass, both of which the caller treats as "use the heuristic". +function OverworldBattle.enemyTexture() + if not (session and session.textures) then return nil end + return session.textures.enemy +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 + -- a LET'S GO capture session empties the player's side the same way + -- BACK SPRITES does: that mon is simply not in this shot, so no pic is + -- rendered for it and no shadow lands under it + local cap = BattleScene.capture + if not OverworldBattle.backPinned() + and not (cap and cap.hidePlayer) 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) + -- the LET'S GO capture overlay -- the timing ring, the ball readout, + -- the grade splash -- drawn last in the same GB frame the engine's + -- own HUD drew in, so it letterboxes and chunks identically + local cap = BattleScene.capture + if cap and cap.drawGB then pcall(cap.drawGB, self) end + 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 + -- a capture session shows NO player side at all -- not even the + -- pinned back pic BACK SPRITES would keep on the menu + local cap = BattleScene.capture + if cap and cap.hidePlayer then return 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 + -- While a capture session is being AIMED the box is empty -- the + -- battle's phase is parked, so there is no message in it -- and it + -- covers the bottom third of the frame, which is exactly the room a + -- throw needs to wind up in. So it comes off entirely for those + -- frames and is back the instant a message has something to say. + local cap = BattleScene.capture + if cap and cap.hideTextBox then return end + return withoutBoxFill(self, innerText) + end + + -- Move animations are authored against the pics' fixed slots, and a single + -- animation reaches across both sides, so there is no per-side offset to + -- give them. They ride the average, which is where the pair's centre went + -- -- a few pixels at most, and it keeps a hit landing on the mon it is + -- aimed at instead of drifting off it. + innerAnim = BattleState.drawAnimLayer + function BattleState:drawAnimLayer(colorized) + local shot = self.dramaticShapeShot + if not shot then return innerAnim(self, colorized) end + -- Move animations are authored against the pics' old fixed slots, and one + -- animation reaches across both sides, so there is no per-side offset to + -- give them. They ride to where the PAIR went: the midpoint of the two + -- mons' projected positions, less the midpoint of the slots they used to + -- sit in. A hit still lands on the mon it is aimed at. + -- + -- And they ride the pair's SEPARATION as well, because the mons + -- themselves do. Both are geometry standing on the map, so the camera + -- sizes them: zoom in and they grow, swing round to side-on and the two + -- marks close up as the axis foreshortens. A layer that only slid would + -- have held the authored 106-pixel spacing through all of it -- a beam + -- fired between two mons that are no longer that far apart, ending in + -- the air beside the one it was aimed at. Scaling about the same + -- midpoint keeps every authored offset the same fraction of the gap it + -- was authored as. + local a = OverworldBattle.ANCHOR + -- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side + -- contributes no movement at all and the pair's centre has gone half as + -- far as the foe's mark did. + local px, py = shot.player[1], shot.player[2] + if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end + local cx, cy = (shot.enemy[1] + px) / 2, (shot.enemy[2] + py) / 2 + local ax = (a.enemy[1] + a.player[1]) / 2 + local ay = (a.enemy[2] + a.player[2]) / 2 + love.graphics.push() + love.graphics.translate(cx - ax, cy - ay) + -- Clamped, and skipped outright if the marks ever coincide: a + -- degenerate projection must leave the effects the size they were + -- rather than collapse them to nothing or blow them across the screen. + local k = OverworldBattle.animScale(shot, px, py) + if k ~= 1 then + love.graphics.translate(ax, ay) + love.graphics.scale(k, k) + love.graphics.translate(-ax, -ay) + end + local ok, err = pcall(innerAnim, self, colorized) + love.graphics.pop() + if not ok then error(err, 0) end + end + + -- The engine's flash has a SECOND half, and it is the one that reaches the + -- menu. Beside the white rectangle (dropped above) the flash moves are + -- driven by a BGP palette fade -- BGP_LIGHT and friends -- which the + -- colorized pipeline applies in drawZonePass to the WHOLE background + -- canvas. That canvas carries the HUD glyphs and the text box, so a fade + -- meant for the two mons washed the menu out with them. + -- + -- The fade is left switched on for the pics, which read it through + -- picImage, and switched off for the zone pass alone. So the mons flash + -- and the furniture around them does not. + -- + -- The zone pass has a SECOND thing it paints, and this is the one that + -- reads as the menu box flashing. A screen shake makes it fill every zone + -- with the zone's own color 0 before it draws the offset copy -- the + -- hardware showing empty BG in the strip the shake vacated. On a white + -- battle field that fill is invisible; over a world it is an opaque white + -- sheet across the whole frame, and since a shake program alternates + -- offset and no-offset frames (SE_SHAKE_SCREEN steps dx 1, 0, 1, 0...) it + -- switches on and off a few times a second. It is dropped: the background + -- here is the map, so what the shake vacates should show the map. + local innerZone = BattleState.drawZonePass + function BattleState:drawZonePass(src, sx, sy) + if not self.dramaticShapeShot then return innerZone(self, src, sx, sy) end + -- shadow the method on the instance for this call only; putting the + -- field back to whatever it was (normally nil) lets the class method be + -- found again + local had = rawget(self, "activeBgp") + self.activeBgp = function() return nil end + local g = love.graphics + local rectangle = g.rectangle + g.rectangle = function(mode, ...) + -- the pass draws no other rectangle; the shake still shifts the copy + if mode == "fill" then return end + return rectangle(mode, ...) + end + local ok, err = pcall(innerZone, self, src, sx, sy) + g.rectangle = rectangle + self.activeBgp = had + if not ok then error(err, 0) end + end + + innerHUDs = BattleState.drawHUDs + function BattleState:drawHUDs(slide) + -- Normally the HUDs have already been drawn this frame, snapped out to the + -- window's edges and composited into the world image (snapHUDs). Drawing + -- them here as well would show each block twice, once in each place. + if self.dramaticShapeShot and snapped() then return end + return innerHUDs(self, slide) + end + + BattleState.dramaticShapeBattleHook = true +end + +-- Whether each HUD block is on screen this frame. +-- +-- READ-ONLY duplicates of drawHUDs' own two guards, because there is no seam +-- that reports "the enemy HUD is up". A panel under a HUD that is not there +-- would be a frosted slab floating in the arena, so it is worth mirroring; +-- the worst a future engine change can do is show an empty one for a frame, +-- never break a battle. +function OverworldBattle.hudLive(battle, slide) + local enemy = battle.enemy and not battle.showEnemyTrainer + and not battle.enemySendingOut + and not battle:growInScale(battle.enemy) and slide == 0 + and not battle.enemy.fainted + local player = battle.player and not (battle.safari or battle.demo) + and not battle.showPlayerBack and slide == 0 + return enemy and true or false, player and true or false +end + +-- ------- the snapped composite +-- +-- The engine's own HUD layer, rendered into a texture. +-- +-- One thing is falsified for the render, and it is falsified because this layer +-- never reaches the battle's zone pass -- it is composited into the world image, +-- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves +-- the HP bar's fill as DMG gray for the zone pass to colour by region (#229); +-- answered false, it tints its own greens and reds instead, exactly as it does +-- on the flat path. +-- +-- Shadowed on the instance for this call only, the way drawZonePass shadows +-- activeBgp: putting the field back to whatever it was (normally nil) lets the +-- class method be found again. +function OverworldBattle.hudTexture(battle, slide) + if not (innerHUDs and battle) then return nil end + local had = rawget(battle, "colorMode") + battle.colorMode = function() return false end + local ok, layer = pcall(BattleHud.layerTexture, + BattleScene.GB_W, BattleScene.GB_H, + function() innerHUDs(battle, slide) end) + battle.colorMode = had + return ok and layer or nil +end + +-- Draw both HUD blocks into the world image at the window's edges, each on its +-- own frosted panel. Returns true when the frame's HUDs are up there and the +-- in-frame draw must be skipped; false leaves the battle screen's own HUD +-- exactly as it was before any of this existed. +-- +-- Both bands are blitted whether or not that side's HUD is LIVE, because a band +-- carries more than the HUD: the pokeball rows of the intro and of an enemy +-- faint, and the safari ball count, all draw in these rows and belong at the +-- same edge as the block they share it with. The panels are the ones that +-- follow hudLive -- frosted glass under nothing is a slab floating in the arena. +function OverworldBattle.snapHUDs(battle, shot) + if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then + return false + end + -- With a headset live the HUDs stay IN the GB frame -- the classic + -- slots, on the glass drawHudPanels lays for the unsnapped path. Both + -- of VR's battle screens (the floating panel and the pokedex's) crop + -- to the letterbox, and a block snapped out to the window's edge would + -- be cropped away with the window around it. + local okV, vr = pcall(V.require, "VR") + if okV and vr and vr.active and vr.active() then return false end + local slide = (battle.introSlide or 0) * 4 + local rects, bandX = OverworldBattle.snapRects(shot) + local enemy, player = OverworldBattle.hudLive(battle, slide) + local live = {} + if not isIOS() then + if enemy then live.enemy = rects.enemy end + if player then live.player = rects.player end + end + -- The text box's frost panel normally goes into this same world-canvas pass. + -- On iOS that panel is mirrored upward by the Canvas-to-Canvas path, creating + -- the large ghost rectangle behind the Pokemon. Keep the box border/text but + -- skip only this frosted backing on iOS. + if not isIOS() then + for key, rect in pairs(OverworldBattle.textRects(battle)) do + live[key] = toWorld(rect, shot) + end + end + local layer = OverworldBattle.hudTexture(battle, slide) + if not layer then return false end + + local g = love.graphics + local prevCanvas = g.getCanvas() + local prevBlend, prevAlpha = g.getBlendMode() + local ok, err = pcall(function() + g.setCanvas(shot.canvas) + g.setBlendMode("alpha") + for _, rect in pairs(live) do BattleHud.panel(rect, shot, true) end + g.setColor(1, 1, 1, 1) + for side, band in pairs(OverworldBattle.HUD_BAND) do + local quad = g.newQuad(band[1], band[2], band[3], band[4], + BattleScene.GB_W, BattleScene.GB_H) + local x = bandX[side] + band[1] * shot.scale + local targetY = shot.ly + band[2] * shot.scale + + if isIOS() then + -- Keep the player's HUD exactly where it currently appears on the + -- right. Only the enemy band needs its mirrored destination corrected. + local y = targetY + if side == "enemy" then + y = shot.ph - targetY - band[4] * shot.scale + end + + -- iOS presents this Canvas-to-Canvas HUD texture upside down. + g.draw(layer, quad, x, y, 0, + shot.scale, -shot.scale, 0, band[4]) + else + g.draw(layer, quad, x, targetY, 0, + shot.scale, shot.scale) + end + end + end) + if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end + g.setBlendMode(prevBlend or "alpha", prevAlpha) + g.setColor(1, 1, 1, 1) + if not ok then error(err, 0) end + return true +end + +-- Lay the frosted glass down under whichever HUD and box are about to draw, +-- and record which way the glyphs have to flip. +-- +-- The panels are the fallback path only: normally the HUDs are snapped out to +-- the window's edges and their glass, and the box's, went into the world image +-- with them (snapHUDs). The VERDICT is needed either way -- the box's ink is +-- drawn here, in the GB frame, whichever path laid the glass under it. +function OverworldBattle.drawHudPanels(battle) + local shot = battle.dramaticShapeShot + if not shot then return end + if snapped() then + return + end + local slide = (battle.introSlide or 0) * 4 + local enemy, player = OverworldBattle.hudLive(battle, slide) + local rect = OverworldBattle.HUD_RECT + local live = {} + if enemy then live.enemy = rect.enemy end + if player then live.player = rect.player end + for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end + if not next(live) then return end + for _, r in pairs(live) do BattleHud.panel(r, shot) end +end + +return OverworldBattle diff --git a/lib/Pokeball.lua b/lib/Pokeball.lua new file mode 100644 index 0000000..1ce0e83 --- /dev/null +++ b/lib/Pokeball.lua @@ -0,0 +1,594 @@ +-- A Poke Ball as real geometry: the prop the LET'S GO capture mode throws. +-- +-- The mod has never drawn a ball in 3D -- the one the engine tosses is a 2D +-- sprite inside the battle's move-animation layer. This is a ball that can +-- fly through the arena, hang in the air in front of the camera, hinge its +-- lid open, drink a Pokemon in, click shut, rock on the ground and burst +-- back open -- all of it depth-tested, sun-shadowed and hour-tinted like +-- everything else in the diorama, because it is a mesh in Voxel3D's own +-- format going through Voxel3D's own shader. +-- +-- ------- how it is built +-- +-- Two lat/long hemisphere shells that meet at the equator -- the WHITE base +-- and the coloured LID -- each carrying its half of the black band as a +-- slightly bulged latitude belt, so the two halves separate exactly where +-- the real ball separates. The button is a little cylinder standing out of +-- the base's front; the interior is sealed with two pale discs so an open +-- ball shows a shell with a floor rather than a view through to the far +-- wall's backface. Colour is a palette texture one texel per material and +-- one ROW per ball tier (POKE/GREAT/ULTRA/MASTER/SAFARI), exactly the +-- HordeGun/Pokedex scheme -- so GREAT is blue and ULTRA wears its yellow +-- band without a second mesh, just a different V coordinate. +-- +-- Shade is baked per vertex from the surface normal with StadiumStage's +-- fitted constants, which is this mod's answer for anything curved: the +-- ball's sun side and belly read as a sphere under the same southeastern +-- sun the roofs are lit by. +-- +-- ------- how it animates +-- +-- The HordeGun way: a handful of scalar timers advanced by update(dt) and +-- consumed as matrix terms at draw time. No skeleton, no keyframes -- +-- lid is a hinge matrix about the back of the equator, the wobble is a +-- decaying rotateZ about the ground contact point, the caught click is a +-- squash pulse, the stars are one shared quad drawn a few times facing the +-- eye. The ball owns its POSE only; where it IS (the throw arc, the drop) +-- is the caller's problem, which is what keeps this file a prop and not a +-- game mode. +-- +-- Nothing here touches love.* until something has to be drawn, so the +-- module loads and the state machine runs headless -- the test suite +-- exercises the phases without a GPU. + +-- 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 = {} +Pokeball.__index = Pokeball + +-- ------- the ball's measurements, in world pixels +-- +-- A map cell is 16 and a full-size mon card is 16 wide, so a 4.4-pixel ball +-- sits in the hand and against a Pokemon at about the proportion the games +-- draw: unmissable in the foreground, believable at the far cell. +Pokeball.R = 2.2 + +-- the black belt: half-height as a latitude angle, and how far the belt +-- bulges past the shell so it reads as a band and not a painted stripe +local BAND_LAT = 0.16 +local BAND_R = 1.045 + +-- lid hinge: at the BACK of the equator (-Z), opening backward. 2.0 rad is +-- past upright -- the mouth gapes at the sky, which is the capture pose. +local HINGE_Z = -0.86 -- as a fraction of R +local LID_OPEN = 2.0 + +-- tessellation: enough that the silhouette is round at held-ball size, +-- cheap enough that six of these would not show on a phone's frame budget +local LON = 14 +local LAT = 5 + +-- pose timing +local LID_RATE = 6.5 -- lid open/close, in lid-fractions per second +local BURST_RATE = 14 -- the breakout pop is a violent open +local WOBBLE_T = 0.85 -- one rock, seconds +local WOBBLE_A = 0.38 -- how far it tips, radians +local PULSE_T = 0.14 -- the caught click's squash pulse +local STAR_T = 0.9 -- the caught stars' life +local GLOW_DECAY = 2.2 -- additive glow, per second + +-- ------- palette +-- +-- One texel per material (columns), one row per ball tier. Alpha stays 1 +-- everywhere: the voxel shader discards below 0.5 (Voxel3D's SHADER), so a +-- translucent texel is an invisible one. +local SLOTS = { TOP = 1, BOTTOM = 2, BAND = 3, RING = 4, FACE = 5, + INNER = 6, GLOW = 7, STAR = 8 } +local SLOT_N = 8 + +local TIERS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL", + "SAFARI_BALL" } + +local COLORS = { + POKE_BALL = { top = { 0.86, 0.16, 0.16 }, band = { 0.12, 0.12, 0.13 }, + bottom = { 0.93, 0.93, 0.95 } }, + GREAT_BALL = { top = { 0.25, 0.45, 0.88 }, band = { 0.12, 0.12, 0.13 }, + bottom = { 0.93, 0.93, 0.95 } }, + ULTRA_BALL = { top = { 0.22, 0.22, 0.26 }, band = { 0.85, 0.70, 0.18 }, + bottom = { 0.93, 0.93, 0.95 } }, + MASTER_BALL = { top = { 0.48, 0.22, 0.66 }, band = { 0.16, 0.13, 0.19 }, + bottom = { 0.93, 0.93, 0.95 }, + glow = { 1.0, 0.72, 0.92 } }, + SAFARI_BALL = { top = { 0.47, 0.52, 0.26 }, band = { 0.36, 0.27, 0.16 }, + bottom = { 0.90, 0.88, 0.80 } }, +} + +local SHARED = { + ring = { 0.28, 0.28, 0.30 }, + face = { 0.96, 0.96, 0.97 }, + inner = { 0.72, 0.70, 0.68 }, + glow = { 1.00, 0.92, 0.65 }, + star = { 1.00, 0.85, 0.25 }, +} + +local function tierRow(ball) + for i, id in ipairs(TIERS) do + if id == ball then return i end + end + return 1 -- an unknown ball is a plain POKE BALL +end + +-- palette texel centres +local function uvFor(slot, row) + return (slot - 0.5) / SLOT_N, (row - 0.5) / #TIERS +end + +local palette = nil +local function paletteTexture() + if palette ~= nil then return palette or nil end + local ok, img = pcall(function() + local data = love.image.newImageData(SLOT_N, #TIERS) + for row, id in ipairs(TIERS) do + local c = COLORS[id] + local function put(slot, rgb) + data:setPixel(slot - 1, row - 1, rgb[1], rgb[2], rgb[3], 1) + end + put(SLOTS.TOP, c.top) + put(SLOTS.BOTTOM, c.bottom) + put(SLOTS.BAND, c.band) + put(SLOTS.RING, SHARED.ring) + put(SLOTS.FACE, SHARED.face) + put(SLOTS.INNER, SHARED.inner) + put(SLOTS.GLOW, c.glow or SHARED.glow) + put(SLOTS.STAR, SHARED.star) + end + local tex = love.graphics.newImage(data) + tex:setFilter("nearest", "nearest") + return tex + end) + palette = ok and img or false + return palette or nil +end + +-- ------- shade +-- +-- StadiumStage's fitted form of Voxel3D.FACE_SHADE: the same southeastern +-- sun, answered for an arbitrary normal instead of one of six faces. +local function shadeFor(nx, ny, nz) + local s = 0.7725 + nx * 0.06 + ny * 0.225 + nz * 0.11 + return math.max(0.30, math.min(1.00, s)) +end + +-- ------- mesh building +-- +-- Everything below appends {x,y,z, u,v, shade} rows plus triangle indices. +-- Quads go through the shared corner order; the discs use a degenerate +-- fourth vertex, which the rasteriser drops as the zero-area triangle it is. +local function quad(verts, map, a, b, c, d) + local n = #verts + verts[n + 1], verts[n + 2], verts[n + 3], verts[n + 4] = a, b, c, d + Voxel3D.pushQuad(map, n / 4) +end + +local R = Pokeball.R +local TAU = math.pi * 2 + +-- a latitude zone of the sphere between phi0 and phi1 (radians from the +-- equator, north positive), at radiusK times the shell radius +local function zone(verts, map, phi0, phi1, rows, slot, row, radiusK) + local u, v = uvFor(slot, row) + local r = R * (radiusK or 1) + for i = 0, rows - 1 do + local pa = phi0 + (phi1 - phi0) * (i / rows) + local pb = phi0 + (phi1 - phi0) * ((i + 1) / rows) + for j = 0, LON - 1 do + local ta = TAU * (j / LON) + local tb = TAU * ((j + 1) / LON) + local function corner(phi, th) + local nx = math.cos(phi) * math.sin(th) + local ny = math.sin(phi) + local nz = math.cos(phi) * math.cos(th) + return { nx * r, ny * r, nz * r, u, v, shadeFor(nx, ny, nz) } + end + quad(verts, map, corner(pa, ta), corner(pa, tb), + corner(pb, tb), corner(pb, ta)) + end + end +end + +-- a disc in a y-plane, sealed with fan quads about the centre +local function disc(verts, map, y, radius, slot, row, up) + local u, v = uvFor(slot, row) + local sh = shadeFor(0, up and 1 or -1, 0) + local centre = { 0, y, 0, u, v, sh } + for j = 0, LON - 1 do + local ta = TAU * (j / LON) + local tb = TAU * ((j + 1) / LON) + local a = { radius * math.sin(ta), y, radius * math.cos(ta), u, v, sh } + local b = { radius * math.sin(tb), y, radius * math.cos(tb), u, v, sh } + quad(verts, map, centre, a, b, centre) + end +end + +-- the button: a ring wall and its face, standing out of the shell along +Z +local function button(verts, map, row) + local BLON = 10 + local function ringWall(rad, z0, z1, slot) + local u, v = uvFor(slot, row) + for j = 0, BLON - 1 do + local ta = TAU * (j / BLON) + local tb = TAU * ((j + 1) / BLON) + local function at(th, z) + local nx, ny = math.cos(th), math.sin(th) + return { rad * nx, rad * ny, z, u, v, shadeFor(nx, ny, 0) } + end + quad(verts, map, at(ta, z0), at(tb, z0), at(tb, z1), at(ta, z1)) + end + end + local function faceDisc(rad, z, slot) + local u, v = uvFor(slot, row) + local sh = shadeFor(0, 0, 1) + local centre = { 0, 0, z, u, v, sh } + for j = 0, BLON - 1 do + local ta = TAU * (j / BLON) + local tb = TAU * ((j + 1) / BLON) + local a = { rad * math.cos(ta), rad * math.sin(ta), z, u, v, sh } + local b = { rad * math.cos(tb), rad * math.sin(tb), z, u, v, sh } + quad(verts, map, centre, a, b, centre) + end + end + -- the wall starts inside the shell so the junction never shows a gap + ringWall(0.75, R * 0.90, R + 0.30, SLOTS.RING) + faceDisc(0.75, R + 0.30, SLOTS.RING) + ringWall(0.45, R + 0.30, R + 0.42, SLOTS.RING) + faceDisc(0.45, R + 0.42, SLOTS.FACE) +end + +-- one tier's meshes, memoised: { base = , lid = , spark = } +-- +-- spark is a shared unit card (x -0.5..0.5, y 0..1, z 0) wearing one texel; +-- the glow disc, the beam and every star are that card under a matrix. +local meshes = {} +local function meshesFor(ball) + local row = tierRow(ball) + local hit = meshes[row] + if hit ~= nil then return hit or nil end + + local ok, built = pcall(function() + local bv, bm = {}, {} + -- the base: white bowl from the south pole up to the band, its half of + -- the band, the interior floor and the button on the front + zone(bv, bm, -math.pi / 2, -BAND_LAT, LAT, SLOTS.BOTTOM, row) + zone(bv, bm, -BAND_LAT, 0, 1, SLOTS.BAND, row, BAND_R) + disc(bv, bm, -0.06, R * 0.97, SLOTS.INNER, row, true) + button(bv, bm, row) + + local lv, lm = {}, {} + -- the lid: its half of the band up to the coloured dome, and its pale + -- underside, which is what shows once the hinge tips it back + zone(lv, lm, 0, BAND_LAT, 1, SLOTS.BAND, row, BAND_R) + zone(lv, lm, BAND_LAT, math.pi / 2, LAT, SLOTS.TOP, row) + disc(lv, lm, 0.06, R * 0.97, SLOTS.INNER, row, false) + + local base = Voxel3D.newMesh(bv, bm) + local lid = Voxel3D.newMesh(lv, lm) + if not (base and lid) then return nil end + + local function card(slot) + local u, v = uvFor(slot, row) + local cv, cm = {}, {} + quad(cv, cm, { -0.5, 0, 0, u, v, 1 }, { 0.5, 0, 0, u, v, 1 }, + { 0.5, 1, 0, u, v, 1 }, { -0.5, 1, 0, u, v, 1 }) + return Voxel3D.newMesh(cv, cm) + end + return { base = base, lid = lid, + glow = card(SLOTS.GLOW), star = card(SLOTS.STAR) } + end) + meshes[row] = (ok and built) or false + return meshes[row] or nil +end + +-- dropped so a lost GL context (Android resume) rebuilds everything +function Pokeball.invalidate() + meshes = {} + palette = nil +end + +-- ------- an instance: one ball with a pose +-- +-- Loads and runs without graphics; only draw() and cast() want a GPU. +function Pokeball.new(ball) + return setmetatable({ + ball = ball or "POKE_BALL", + pos = { 0, 0, 0 }, -- world pixels, the ball's CENTRE + yaw = 0, -- which way the button faces + scale = 1, + spin = 0, -- visual spin about the vertical, rad/s + tumble = 0, -- end-over-end in flight, rad/s + roll = 0, -- SCREEN-PLANE spin, rad/s: rotation about + -- the axis out of the ball's face, which + -- with the yaw at the camera reads as the + -- ball turning clockwise/counter-clockwise + -- to the viewer -- the curveball wind-up + spinAngle = 0, tumbleAngle = 0, rollAngle = 0, + lid = 0, lidTarget = 0, lidRate = LID_RATE, + wobbleT = nil, wobbleDir = 1, + pulse = nil, -- the caught click's squash + glow = 0, + stars = nil, -- caught celebration, or nil + visible = true, + }, Pokeball) +end + +-- ------- the verbs the capture flow speaks + +function Pokeball:open() + self.lidTarget, self.lidRate = 1, LID_RATE + self.glow = 1 +end + +function Pokeball:close() + self.lidTarget, self.lidRate = 0, LID_RATE +end + +-- one rock on the ground; dir alternates shakes. Returns how long it takes, +-- so the caller can sequence the pauses between shakes. +function Pokeball:rock(dir) + self.wobbleT = 0 + self.wobbleDir = dir or 1 + return WOBBLE_T +end + +-- the caught click: squash pulse, a soft flash, and the stars +function Pokeball:catchClick() + self.pulse = 0 + self.glow = 0.6 + local stars = {} + for i = 1, 6 do + stars[i] = { t = -0.04 * (i - 1), th = TAU * (i - 1) / 6 + 0.4 } + end + self.stars = stars +end + +-- the breakout: the lid blown open and a hard flash +function Pokeball:burst() + self.lidTarget, self.lidRate = 1, BURST_RATE + self.glow = 1 +end + +function Pokeball:busy() + return self.wobbleT ~= nil or self.pulse ~= nil + or math.abs(self.lid - self.lidTarget) > 0.02 +end + +function Pokeball:update(dt) + -- lid toward its target, at whatever violence was asked for + local d = self.lidTarget - self.lid + if d ~= 0 then + local step = self.lidRate * dt + if math.abs(d) <= step then + -- arriving CLOSED from open is the shut click: the squash pulse + if self.lid > self.lidTarget then self.pulse = self.pulse or 0 end + self.lid = self.lidTarget + else + self.lid = self.lid + (d > 0 and step or -step) + end + end + if self.wobbleT then + self.wobbleT = self.wobbleT + dt + if self.wobbleT >= WOBBLE_T then self.wobbleT = nil end + end + if self.pulse then + self.pulse = self.pulse + dt + if self.pulse >= PULSE_T then self.pulse = nil end + end + if self.stars then + local live = false + for _, s in ipairs(self.stars) do + s.t = s.t + dt + if s.t < STAR_T then live = true end + end + if not live then self.stars = nil end + end + self.glow = math.max(0, self.glow - GLOW_DECAY * dt) + self.spinAngle = self.spinAngle + self.spin * dt + self.tumbleAngle = self.tumbleAngle + self.tumble * dt + self.rollAngle = self.rollAngle + self.roll * dt +end + +-- ------- pose as a matrix + +local function smooth(t) + if t <= 0 then return 0 end + if t >= 1 then return 1 end + return t * t * (3 - 2 * t) +end + +function Pokeball:matrix() + local m = Mat4.mul(Mat4.translate(self.pos[1], self.pos[2], self.pos[3]), + Mat4.rotateY(self.yaw)) + if self.wobbleT then + -- a decaying rock about the ground contact: tip, cross through centre, + -- tip the other way, settle + local t = self.wobbleT / WOBBLE_T + local a = WOBBLE_A * math.sin(TAU * t) * (1 - t) * self.wobbleDir + m = Mat4.mul(m, Mat4.mul(Mat4.translate(0, -R * self.scale, 0), + Mat4.mul(Mat4.rotateZ(a), + Mat4.translate(0, R * self.scale, 0)))) + end + if self.spinAngle ~= 0 then m = Mat4.mul(m, Mat4.rotateY(self.spinAngle)) end + if self.tumbleAngle ~= 0 then + m = Mat4.mul(m, Mat4.rotateX(self.tumbleAngle)) + end + -- the roll turns about the ball's own face axis, so with the yaw aimed + -- at the camera it reads as clockwise/counter-clockwise on screen + if self.rollAngle ~= 0 then + m = Mat4.mul(m, Mat4.rotateZ(self.rollAngle)) + end + local k = self.scale + if self.pulse then + -- the click: a quick squash and back, more felt than seen + local p = math.sin((self.pulse / PULSE_T) * math.pi) * 0.14 + m = Mat4.mul(m, Mat4.scale(k * (1 + p), k * (1 - p), k * (1 + p))) + elseif k ~= 1 then + m = Mat4.mul(m, Mat4.scale(k, k, k)) + end + return m +end + +-- the hinge: the lid's own extra transform about the back of the equator +local function lidMatrix(open) + if open <= 0 then return nil end + local a = -LID_OPEN * smooth(open) + local hz = HINGE_Z * R + return Mat4.mul(Mat4.translate(0, 0, hz), + Mat4.mul(Mat4.rotateX(a), Mat4.translate(0, 0, -hz))) +end + +-- where the open mouth is, for aiming the capture beam +function Pokeball:mouth() + return self.pos[1], self.pos[2] + R * 0.4 * self.scale, self.pos[3] +end + +-- ------- drawing +-- +-- Assumes a live Voxel3D scene (between beginScene and endScene), exactly +-- like Stadium.draw. Seams and glass are off for the duration: the ball is +-- not on the voxel grid and does not wear the tileset atlas. +local function eyeYaw(x, z) + local eye = Voxel3D.eye + if not eye then return 0 end + return math.atan2(eye[1] - x, eye[3] - z) +end + +function Pokeball:draw(pull) + if not self.visible then return end + local m = meshesFor(self.ball) + local pal = paletteTexture() + if not (m and pal) then return end + Voxel3D.seams(false) + Voxel3D.glass(false) + local model = self:matrix() + Voxel3D.draw(m.base, pal, model, pull) + local lidM = lidMatrix(self.lid) + Voxel3D.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model, pull) + + -- the additive dressing: the open-mouth glow and the caught stars. + -- Depth writes are off under "add" (Voxel3D.blend), so these can never + -- punch holes for later draws. + local anythingAdd = (self.glow > 0.05 and self.lid > 0.1) or self.stars + if anythingAdd then + Voxel3D.blend("add") + if self.glow > 0.05 and self.lid > 0.1 then + -- a pulsing octahedron of light standing in the mouth: two crossed + -- cards read from every seat in the house + local gx, gy, gz = self:mouth() + local s = R * (1.1 + 0.25 * self.glow) * self.scale + for i = 0, 1 do + local card = Mat4.mul(Mat4.translate(gx, gy, gz), + Mat4.mul(Mat4.rotateY(eyeYaw(gx, gz) + i * math.pi / 2), + Mat4.scale(s, s, 1))) + Voxel3D.draw(m.glow, pal, card, pull) + end + end + if self.stars then + for _, s in ipairs(self.stars) do + if s.t > 0 and s.t < STAR_T then + local t = s.t / STAR_T + local rr = (R + 4.5 * t) * self.scale + local sx = self.pos[1] + math.sin(s.th) * rr + local sz = self.pos[3] + math.cos(s.th) * rr + local sy = self.pos[2] + (R + 7 * t - 5 * t * t) * self.scale + local sc = 1.1 * (1 - t) + local card = Mat4.mul(Mat4.translate(sx, sy, sz), + Mat4.mul(Mat4.rotateY(eyeYaw(sx, sz)), + Mat4.mul(Mat4.rotateZ(TAU * t * 0.5), + Mat4.scale(sc, sc, 1)))) + Voxel3D.draw(m.star, pal, card, pull) + end + end + end + Voxel3D.blend(nil) + end + Voxel3D.glass(true) + Voxel3D.seams(true) +end + +-- the capture beam: a crossed pair of additive cards stretched from the +-- ball's mouth to the mon it is drinking in. Separate from draw() because +-- the caller owns the far end and the fade. +function Pokeball:drawBeam(tx, ty, tz, width, strength, pull) + if not self.visible then return end + local m = meshesFor(self.ball) + local pal = paletteTexture() + if not (m and pal) then return end + local x, y, z = self:mouth() + local dx, dy, dz = tx - x, ty - y, tz - z + local len = math.sqrt(dx * dx + dy * dy + dz * dz) + if len < 0.5 then return end + dx, dy, dz = dx / len, dy / len, dz / len + -- two perpendiculars to the beam axis + local ux, uy, uz + if math.abs(dy) < 0.94 then + ux, uy, uz = -dz, 0, dx -- cross(d, worldUp), unnormalised + local l = math.sqrt(ux * ux + uz * uz) + ux, uz = ux / l, uz / l + else + ux, uy, uz = 1, 0, 0 + end + local vx = dy * uz - dz * uy + local vy = dz * ux - dx * uz + local vz = dx * uy - dy * ux + local w = (width or R) * (strength or 1) + Voxel3D.seams(false) + Voxel3D.glass(false) + Voxel3D.blend("add") + -- the unit card is x -0.5..0.5, y 0..1: columns map its x to a + -- perpendicular and its y to the full run of the axis + local a = { ux * w, dx * len, vx, x, + uy * w, dy * len, vy, y, + uz * w, dz * len, vz, z, + 0, 0, 0, 1 } + local b = { vx * w, dx * len, ux, x, + vy * w, dy * len, uy, y, + vz * w, dz * len, uz, z, + 0, 0, 0, 1 } + Voxel3D.draw(m.glow, pal, a, pull) + Voxel3D.draw(m.glow, pal, b, pull) + Voxel3D.blend(nil) + Voxel3D.glass(true) + Voxel3D.seams(true) +end + +-- ------- the sun's view +-- +-- The same two shells under the same matrix, so the shadow on the ground is +-- the pose the camera sees. The caller folds a term into the shadow +-- signature while a ball is live (the sun pass is cached -- see +-- BattleScene.shadowSignature) or this freezes on its first frame. +function Pokeball:cast(shadowMap) + if not self.visible then return end + local m = meshesFor(self.ball) + local pal = paletteTexture() + if not (m and pal) then return end + local model = self:matrix() + shadowMap.draw(m.base, pal, model) + local lidM = lidMatrix(self.lid) + shadowMap.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model) +end + +-- a term for the arena's cached shadow signature: quantised, so the cache +-- only re-renders when the ball has visibly moved +function Pokeball:signature() + if not self.visible then return "" end + return table.concat({ math.floor(self.pos[1] * 4), math.floor(self.pos[2] * 4), + math.floor(self.pos[3] * 4), math.floor(self.lid * 8), + self.wobbleT and math.floor(self.wobbleT * 30) or -1 }, + ",") +end + +return Pokeball diff --git a/lib/Stadium.lua b/lib/Stadium.lua index aceeab9..8cccf32 100644 --- a/lib/Stadium.lua +++ b/lib/Stadium.lua @@ -391,6 +391,13 @@ function Stadium.update(dt, battle, groundY) if mon.species then StadiumPack.keep(mon.species) end mon.visible = (mon.rig ~= nil) and onField(battle, side, mon) and not (battler and battler.substituteHP) + -- LET'S GO capture mode: the player's model is out of the shot the + -- same way its card and back pic are (the shrink half of the story is + -- below, AFTER the grow block, which reassigns mon.scale every frame) + local cap = V.require("BattleScene").capture + if side == "player" and cap and cap.hidePlayer then + mon.visible = false + end -- cleared up front, so a side that has just lost its rig cannot leave -- last frame's matrix behind it mon.model_matrix = nil @@ -425,6 +432,16 @@ function Stadium.update(dt, battle, groundY) local okG, grow = pcall(battle.growInScale, battle, battler) mon.scale = (okG and grow) or 1 end + -- LET'S GO capture: the foe drinking into the ball. AFTER the grow + -- block on purpose -- that block reassigns mon.scale every frame, + -- and the first cut of this hook sat above it and was silently + -- clobbered: the model stood at full size over a ball that had + -- supposedly swallowed it. The session's fraction owns the scale + -- for as long as it exists; the frame it clears, the grow block + -- above is already putting the engine's own answer back. + if side == "enemy" and cap and cap.shrink then + mon.scale = cap.shrink + end mon:update(dt or 0) if mon.visible and arena then local cell = arena[side] @@ -491,6 +508,34 @@ function Stadium.guard(side, mon, what, fn) return false end +-- The foe's body for the capture mode's collision and ring, when a MODEL +-- is standing there instead of a pic: its own measured height and +-- footprint, in world pixels. A model stands on the ground, so the body's +-- centre is half its height up. nil whenever no model covers the foe, +-- which sends CatchThrow to its pic measurement instead. +function Stadium.captureBody() + if not session then return nil end + local mon = session.enemy + if not (mon and mon.rig and mon.visible) then return nil end + local okH, h = pcall(mon.worldHeight, mon) + if not (okH and h and h > 0) then return nil end + -- The POSED body, when there is one: a flying Pokemon is nowhere near + -- the mark its cell projects to, and only the pose knows where it went + -- (StadiumMon:bodySpan). The bind-pose figures stand in until the + -- first skin, which is right for everything that keeps its feet down. + local okS, centre, half, girth = pcall(mon.bodySpan, mon) + if okS and centre then + return { r = math.max(5, math.min(16, math.max(girth or 0, half * 0.8))), + yOff = centre, + hh = math.max(4, half) } + end + local okR, r = pcall(mon.worldRadius, mon) + local rr = (okR and r and r > 0) and r or h * 0.4 + return { r = math.max(5, math.min(16, math.max(rr, h * 0.5))), + yOff = h * 0.5, + hh = math.max(4, h * 0.55) } +end + function Stadium.draw(pull) if not session then return end for _, side in ipairs({ "enemy", "player" }) do diff --git a/lib/StadiumMon.lua b/lib/StadiumMon.lua index c148ac2..e8420f5 100644 --- a/lib/StadiumMon.lua +++ b/lib/StadiumMon.lua @@ -456,6 +456,55 @@ function StadiumMon:matrix(x, groundY, z, faceX, faceZ) Mat4.translate(0, -lift, 0)) end +-- How far this Pokemon's LOWEST rendered point stands above the ground it +-- is placed on, in world pixels -- the authored hover the matrix above +-- gives back, actually applied. +-- +-- Derived by repeating that matrix's own arithmetic rather than by +-- re-deriving it in closed form: root scale, the model's floor and the +-- hover cap interact in a way that is easy to get subtly wrong, and a +-- caller that guessed would place things at the feet of a Pokemon that is +-- flying. Which is exactly what a Pidgey does -- it renders a good third +-- of its own height clear of its tile, and anything aimed at its cell +-- mark lands under it. +function StadiumMon:groundGap() + local centre, half = self:bodySpan() + if centre then return math.max(0, centre - half) end + return 0 +end + +-- Where this Pokemon's body actually SITS above the ground it is placed +-- on, and how big it is: the centre height, the half height and the +-- girth, all in world pixels. +-- +-- Measured off the POSED vertices (StadiumRig:posedBounds) and put +-- through this matrix's own scale and lift, so the answer is the shape +-- the camera is about to see. That matters most for the species it is +-- hardest to guess about: a Pidgey's standby animation flies it well +-- clear of its tile, and anything aimed at its cell mark -- a capture +-- ring, a thrown ball's collision -- lands under an empty patch of grass +-- while the bird hovers above it. Nothing static says so; only the pose +-- does. +-- +-- nil before the first skin(), or with no rig: the caller falls back to +-- the bind-pose height, which is right for everything that stands. +function StadiumMon:bodySpan() + local model, rig = self.model, self.rig + if not (model and rig and rig.posedBounds) then return nil end + local okB, lo, hi, girth = pcall(rig.posedBounds, rig) + if not (okB and lo) then return nil end + local root = model.rootScale + if not (root and root > 0) then root = 1 end + local k = root * self:worldHeight() / math.max(model.height, 1e-6) + k = k * (self.scale or 1) + local floor = model.floor or 0 + local hover = math.min(math.max(floor, 0), + StadiumMon.HOVER_CAP * math.max(model.height, 0)) + local lift = (floor - hover) / root + -- the same map the model matrix applies: world = k * (posed - lift) + return k * ((lo + hi) * 0.5 - lift), k * (hi - lo) * 0.5, k * (girth or 0) +end + -- Pose and skin for this frame. Separate from the draw because both the -- SUN and the camera -- and, in a headset, both eyes -- want the same -- skinned mesh, and skinning it once is the whole reason this is worth diff --git a/lib/StadiumRig.lua b/lib/StadiumRig.lua index bfc79f6..e40d052 100644 --- a/lib/StadiumRig.lua +++ b/lib/StadiumRig.lua @@ -728,6 +728,36 @@ function StadiumRig:skin(yaw) end end +-- What this POSE actually occupies, in the rig's own posed space: the +-- vertical span of every skinned vertex, and the furthest any of them +-- stands from the model's vertical axis. +-- +-- Read off the skinned rows rather than off the pack's bind-pose figures, +-- because the two are not the same claim. The bind measurements say how +-- big the model is; a caller placing something ON the Pokemon needs to +-- know where the Pokemon IS, and for a flying species the standby +-- animation carries it a third of its own height off the floor -- a lift +-- that exists only in the posed bones and appears in no static field. +-- +-- Answers nil before the first skin(), which is the caller's cue to fall +-- back to the bind figures. +function StadiumRig:posedBounds() + local lo, hi, r2 = nil, nil, 0 + for _, part in ipairs(self.parts) do + local rows, n = part.rows, part.prim.vertCount + for k = 1, n do + local row = rows[k] + local y = row[2] + if not lo or y < lo then lo = y end + if not hi or y > hi then hi = y end + local d = row[1] * row[1] + row[3] * row[3] + if d > r2 then r2 = d end + end + end + if not lo then return nil end + return lo, hi, math.sqrt(r2) +end + -- ------- which texture each part wears this frame -- -- The eyes. A primitive whose display list carried geo command 0x23 with a diff --git a/main.lua b/main.lua index 2501e96..04b6ba0 100644 --- a/main.lua +++ b/main.lua @@ -104,6 +104,11 @@ local Horde = V.require("Horde") local HordeGun = V.require("HordeGun") local HordeHud = V.require("HordeHud") local HordeSfx = V.require("HordeSfx") +-- LET'S GO: the flick-to-throw capture mode. LetsGo owns the row, the +-- wraps and the experience math; CatchThrow the session (input, arc, +-- ring, choreography); Pokeball the animated prop they throw. +local LetsGo = V.require("LetsGo") +local Pokeball = V.require("Pokeball") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -196,6 +201,19 @@ mod.content.render_pipelines:register("voxel", { -- the atmosphere's own clock (shaft shimmer, drifting motes), on the -- same tick so the beams keep breathing through a dialog box ForestAtmos.update(dt) + -- LET'S GO rides the same always-running tick, and BEFORE the battle's + -- own update on purpose: the capture session poses the Poke Ball here, + -- and OverworldBattle.update renders the arena a moment later -- so + -- the ball each frame draws is the ball that frame computed. Guarded, + -- and loudly: a fault in the capture game must cost the capture game, + -- not the whole voxel pipeline. + do + local okLG, errLG = pcall(LetsGo.update, dt) + if not okLG and not V.letsGoWarned then + V.letsGoWarned = true + mod.log:warn("LET'S GO update failed: %s", tostring(errLG)) + end + end -- The overworld battle rides this hook rather than owning a pipeline of -- its own, because it owns no pass of the FRAME: it draws under a battle -- screen the engine composites, which is not a stage the registry has. @@ -309,6 +327,7 @@ mod.content.render_pipelines:register("voxel", { ChunkMesher.invalidate() -- no map id = every cached mesh ForestAtmos.invalidate() -- shaft/particle meshes and shader sentinels VR.invalidate() -- the mirror, and FBO ids of dead canvases + Pokeball.invalidate() -- the ball's meshes and palette texture end, }) @@ -499,6 +518,22 @@ local SETTINGS = { .. "The foe is still out there on its own tile.", when = function() return stagedBattles() and not VR.enabled() end, full = true }, + -- `full` like the battle rows: this is a GAMEPLAY mode, not a knob on + -- the diorama, so the FULL preset neither sets it nor takes it away. + { LetsGo.setting, + "Pokemon GO-style catching, staged in the 3D battle. Flick the mouse, " + .. "a finger or the right stick to throw the ball at the wild Pokemon " + .. "-- spin it first for a curve -- and land inside the shrinking " + .. "ring for a NICE, GREAT or EXCELLENT that raises the catch odds. " + .. "CATCH ONLY changes nothing else: picking a ball in battle simply " + .. "plays the throw. FULL is the whole Let's Go treatment: wild " + .. "encounters open straight in throwing mode (B backs out to the " + .. "classic menu), Poke/Great/Ultra Balls are half price, and a catch " + .. "pays the whole party experience -- scaled by throw quality, first " + .. "throws, new species and your running catch combo. Needs 3D-BTL " + .. "on; anywhere the staged fight cannot stand, balls quietly throw " + .. "the classic way.", + full = true }, { DayNight.setting, "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " @@ -1136,6 +1171,15 @@ end -- become the same eight buttons. See lib/Horde.lua. Horde.install() +-- ------- LET'S GO capture mode +-- +-- After every other input seam on purpose: while a throw is being aimed +-- the capture's mouse and touch wraps are the OUTERMOST, so the flick is +-- read before anything else can claim the pointer -- and outside the aim +-- they forward every byte untouched. The battle-side wraps (throwBall, +-- safariAction) and the experience hooks install here too. +LetsGo.install() + -- ------- edge-anchored menus stay in the GB frame while a headset is live -- -- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index f8c9edb..9cb33c4 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -406,14 +406,14 @@ local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, -- (nothing to store, nothing for the mod manager to persist) and is offered -- on every platform, saying WHERE? rather than IMPORT where there is no file -- dialog to open -T.eq(#hookedRows, 12, "the options hook added a row per setting, plus the " +T.eq(#hookedRows, 13, "the options hook added a row per setting, plus the " .. "STADIUM ROM action row") local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5] -local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[9] --- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6] and AA --- hookedRows[10]; all three are read where they are used rather than named --- here, because this chunk is one main function and has 200 local slots to --- spend +local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[10] +-- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6], LET'S GO +-- hookedRows[9] and AA hookedRows[11]; all four are read where they are +-- used rather than named here, because this chunk is one main function and +-- has 200 local slots to spend T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label") T.eq(hookedRows[4].value(), "FIT", "and defaults to FIT -- the cut IS the window the flat game already " @@ -446,6 +446,9 @@ T.eq(backRow.value(), "OFF", .. "map, so the classic slot is opt-in") T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true), "on its own key, so it persists beside 3D-BTL rather than over it") +T.eq(hookedRows[9].label, "LET'S GO", "the capture-mode row carries its label") +T.eq(hookedRows[9].value(), "OFF", + "and starts OFF -- a gameplay mode is opt-in, whatever the diorama does") -- stepping writes through to the one place both rows read local settingGame = { save = { options = {} }, mods = { modOptions = {} } } @@ -524,7 +527,7 @@ do local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias") local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") local aaGame = { save = { options = {} }, mods = { modOptions = {} } } -local aa = hookedRows[10] +local aa = hookedRows[11] T.eq(aa.label, "AA", "the anti-aliasing row carries its label") T.eq(aa.value(), "OFF", "and starts off -- supersampling is a cost knob, and a mod must not spend " @@ -6275,6 +6278,95 @@ VB.setting:sync(boxWas) VS.angle = angleWas end)() +-- ------- LET'S GO: the capture mode's arithmetic and the ball's pose +-- +-- The throw itself is a rendered, pointer-driven thing the suite cannot +-- fly, but everything consequential under it is plain arithmetic and runs +-- headless: the scaled experience formula (checked against the published +-- Let's Go table), the combo ladder, and the Pokeball prop's state +-- machine, which must load and animate without a GPU. +;(function() + local lib = run.loader.exports.DRAMATIC_SHAPE.lib + local LetsGo = lib.require("LetsGo") + local Pokeball = lib.require("Pokeball") + local Mat4 = lib.require("Mat4") + + -- the reference points from the community-measured table: a Lv30 + -- Chansey (base exp 395) pays 2371 to a Lv30 receiver and 5497 to a + -- Lv10 one, before any catch bonuses + T.eq(LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, level = 30, + mon = { level = 30 } }, 1), 2371, + "the scaled formula at equal levels is b*L/5 + 1 -- the table's 2371") + T.eq(LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, level = 30, + mon = { level = 10 } }, 1), 5497, + "and a Lv10 receiver pulls the table's 5497 from the same catch -- " + .. "the receiver's own level is in the denominator") + T.check(LetsGo._scaledGain({ defeatedDef = { baseExp = 40 }, level = 3, + mon = { level = 100 } }, 1) >= 1, + "a trivial catch still pays at least one point") + + -- and the same formula pays TRAINER knockouts under FULL, with the + -- wild/trainer 1.5 that a catch never sees (a caught Pokemon is always + -- wild, which is why the catch numbers above are untouched by it) + local wildKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, + level = 30, mon = { level = 30 } }, 1) + local trainerKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, + level = 30, mon = { level = 30 }, + isTrainer = true }, 1) + T.eq(wildKO, 2371, "a wild payout is unchanged by the trainer term") + T.eq(trainerKO, 3556, "a trainer's Pokemon pays the same formula at 1.5") + T.check(trainerKO > wildKO, "which is more than the same wild one") + -- the receiver's own level still drives it, which is the whole point of + -- sharing to the party: the low member gains multiples of the high one + local lowKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, + level = 30, mon = { level = 5 }, + isTrainer = true }, 1) + T.check(lowKO > trainerKO * 2, + "a level 5 party member takes several times what a level 30 one does " + .. "from the very same knockout") + + -- the combo ladder's five tiers + T.eq(LetsGo._comboMult(1), 1.1, "a fresh combo is the 1.1 tier") + T.eq(LetsGo._comboMult(10), 1.1, "which runs to ten") + T.eq(LetsGo._comboMult(11), 1.5, "eleven starts the 1.5 tier") + T.eq(LetsGo._comboMult(21), 2.0, "twenty-one the 2.0") + T.eq(LetsGo._comboMult(31), 2.5, "thirty-one the 2.5") + T.eq(LetsGo._comboMult(41), 3.0, "and forty-one caps it at 3.0") + T.eq(LetsGo._comboMult(999), 3.0, "where it stays") + + -- the row answers the mode, and OFF answers false + T.eq(LetsGo.mode(), false, "LET'S GO defaults to OFF") + + -- the ball: a full open-drop-rock-click life without a GPU + T.eq(Mat4.rotateZ ~= nil, true, "Mat4 grew the roll the wobble rocks on") + do + local m = Mat4.rotateZ(math.pi / 2) + -- row-major: x' of (1,0,0) is m[1], y' is m[5] + T.check(math.abs(m[1]) < 1e-9 and math.abs(m[5] - 1) < 1e-9, + "rotateZ carries +X onto +Y, the right-handed roll") + end + local ball = Pokeball.new("GREAT_BALL") + T.eq(ball.lid, 0, "a fresh ball is shut") + ball:open() + for _ = 1, 60 do ball:update(1 / 60) end + T.eq(ball.lid, 1, "open() hinges the lid fully over its ramp") + T.check(ball.glow < 1, "and the mouth glow decays on its own") + ball:close() + for _ = 1, 60 do ball:update(1 / 60) end + T.eq(ball.lid, 0, "close() brings it back") + local dur = ball:rock(1) + T.check(dur > 0, "a rock reports its duration for the caller's cadence") + T.check(ball:busy(), "and the ball is busy while it rocks") + for _ = 1, math.ceil(dur * 60) + 2 do ball:update(1 / 60) end + T.check(not ball:busy(), "then settles") + ball:catchClick() + T.check(ball.stars ~= nil, "the caught click spawns its stars") + local matrix = ball:matrix() + T.eq(#matrix, 16, "the pose is one model matrix") + for _ = 1, 120 do ball:update(1 / 60) end + T.check(ball.stars == nil, "and the stars burn out on their own") +end)() + Pipelines.reset() run.release() diff --git a/tests/letsgo_shots.lua b/tests/letsgo_shots.lua new file mode 100644 index 0000000..c42c22c --- /dev/null +++ b/tests/letsgo_shots.lua @@ -0,0 +1,309 @@ +-- Driver: photograph the LET'S GO capture mode end to end. +-- +-- One wild encounter under FULL: the battle menu should never be seen -- +-- capture mode opens over it with the ball hanging at the player's empty +-- cell and the ring pulsing on the foe -- then a synthetic mouse flick +-- throws the ball and the beats that follow are photographed: flight, +-- the open-mouth suck, the drop-and-wobble, and the outcome. +-- +-- SHOT_DIR=.scratchpad/letsgo \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_shots.lua \ +-- "/c/Program Files/LOVE/lovec.exe" . +-- +-- SHOT_DIR must already exist. DS_LETSGO_MODE=catching runs the same +-- beats through the bag-menu route instead of the FULL auto-entry. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad" + local MODE = os.getenv("DS_LETSGO_MODE") or "full" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Bag = require("src.inventory.Bag") + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + return + end + local LetsGo = lib.require("LetsGo") + local CatchThrow = lib.require("CatchThrow") + local BattleScene = lib.require("BattleScene") + + -- a spread of levels, because the Let's Go award pays every party + -- member against its OWN level: a low one should pull far more from + -- the same catch than a high one, and a flat payout would hide that + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 45), + Pokemon.new(game.data, "PIKACHU", 10), + Pokemon.new(game.data, "RATTATA", 5), + } + game.save.player.name = "RED" + Bag.add(game.save, "POKE_BALL", 20, game.data) + Bag.add(game.save, "GREAT_BALL", 5, game.data) + -- DS_BALL forces the ball, so a MASTER_BALL run is a guaranteed catch + -- and the experience payout can be read deterministically + local FORCE = os.getenv("DS_BALL") + if FORCE then + Bag.add(game.save, FORCE, 5, game.data) + CatchThrow.lastBall = FORCE + end + local expBefore = {} + for i, m in ipairs(game.save.party) do + expBefore[i] = { exp = m.exp, level = m.level } + end + LetsGo.setting:setValue(MODE, game) + -- DS_RUNG=cards forces the 2D-3D A rung: the control run for the pic + -- measurement path, against the STADIUM models the save may be on + if os.getenv("DS_RUNG") == "cards" then + lib.require("OverworldBattle").setting:setValue(true, game) + U.log("3D-BTL forced to 2D-3D A") + end + U.log("LET'S GO mode: " .. tostring(LetsGo.mode())) + + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(90) + + local battle = BattleState.newWild(game, "PIDGEY", 5) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + -- the wipe and the "Wild X appeared!" chatter: tap until the menu (or + -- capture mode, which under FULL opens the instant the menu would) + U.wait(70) + for i = 1, 80 do + if battle.phase == "menu" or CatchThrow.session() then break end + U.tap(game, "a") + U.wait(6) + if i % 20 == 0 then + U.log(("still driving intro: phase=%s queue=%d anim=%s") + :format(tostring(battle.phase), #(battle.queue or {}), + tostring(battle.animPlaying))) + end + end + + local function phase() + local s = CatchThrow.session() + return s and s.phase or ("battle:" .. tostring(battle.phase)) + end + + if MODE == "catching" then + -- the bag route: ITEM on the menu, then the ball + U.wait(20) + U.log("menu phase: " .. tostring(battle.phase)) + -- cursor to ITEM (menu is 2x2: fight pkmn / item run) -- down once + U.tap(game, "down"); U.wait(4) + U.tap(game, "a"); U.wait(20) + -- the bag opens on the first item; balls were added first + U.tap(game, "a"); U.wait(10) + else + -- FULL: capture mode should have opened on its own + U.wait(30) + end + + U.log("capture phase: " .. phase()) + do + local s = CatchThrow.session() + if s then + local b = s.artBox + U.log(("probe: span=%.1f enemyGB=%.0f,%.0f playerGB=%.0f,%.0f") + :format(s.shot.enemySpan or -1, s.shot.enemy[1], s.shot.enemy[2], + s.shot.player[1], s.shot.player[2])) + U.log(("probe: eye=%.0f,%.0f,%.0f enemyW=%.0f,%.0f playerW=%.0f,%.0f") + :format(s.shot.eye[1], s.shot.eye[2], s.shot.eye[3], + s.enemyPos[1], s.enemyPos[3], + s.playerPos[1], s.playerPos[3])) + if b then + U.log(("probe: artBox ax=%d ay=%d box=%d..%d,%d..%d body r=%.1f yOff=%.1f") + :format(b.ax, b.ay, b.x0, b.x1, b.y0, b.y1, + s.body.r, s.body.yOff)) + else + U.log(("probe: artBox=nil body r=%.1f yOff=%.1f") + :format(s.body.r, s.body.yOff)) + end + end + end + U.shot(game, DIR .. "/1_aim.png") + + -- ------- the reach test + -- + -- Drag the ball to the far corners of the WINDOW -- including well + -- below where the battle's text box used to be -- and report where it + -- actually ends up in the GB frame. A fence shows up here as a ball + -- that stops moving while the pointer keeps going. + do + local W, H = love.graphics.getDimensions() + local s = CatchThrow.session() + if s and love.mousepressed then + local hx, hy = s.handGB[1], s.handGB[2] + love.mousepressed(W * 0.5, H * 0.62, 1, false, 1) + U.wait(2) + local CORNERS = { + { "bottom-centre", 0.50, 0.97 }, + { "bottom-left", 0.06, 0.97 }, + { "bottom-right", 0.94, 0.97 }, + { "top-left", 0.06, 0.10 }, + } + for _, c in ipairs(CORNERS) do + for i = 1, 4 do + if love.mousemoved then + love.mousemoved(W * c[2], H * c[3], 0, 0, false) + end + U.wait(1) + end + local q = CatchThrow.session() + U.log(("reach %-14s -> ball GB %.0f,%.0f (frame is 160x144)") + :format(c[1], q and q.handGB[1] or -1, q and q.handGB[2] or -1)) + if c[1] == "bottom-centre" then + U.shot(game, DIR .. "/1b_drag_low.png") + end + end + -- a slow release is a dead flick: nothing is thrown + if love.mousereleased then + love.mousereleased(W * 0.06, H * 0.10, 1, false, 1) + end + U.wait(30) + U.log("after reach test: " .. phase() + .. (" (ball home was %.0f,%.0f)"):format(hx, hy)) + end + end + + -- A synthetic flick aimed like a hand: from the session's own geometry, + -- pick a release point short of the ring and a sweep speed for a + -- comfortable sigma, so the aim model (release + reach along the flick) + -- lands the ball on the ring centre. Window units; the session maps + -- them back to GB through the live letterbox. + local aim = CatchThrow._aimInfo() + if aim then + local uw, uh = love.graphics.getDimensions() + local function toWin(gx, gy) + return (aim.lx + gx * aim.scale) * uw / aim.pw, + (aim.ly + gy * aim.scale) * uh / aim.ph + end + -- the throw is the swipe's own velocity now: grab the ball and sweep + -- toward the ring at a comfortable ~190 GB px/s for 8 frames. + -- DS_SWIPE overrides it, which is how the strength band is swept: + -- weak should fall short, comfortable should land, hard should still + -- reach rather than sail over. + local SPEED = tonumber(os.getenv("DS_SWIPE") or "") or 190 + local FRAMES = 8 + SPEED_USED = SPEED + local hx, hy = aim.hand[1], aim.hand[2] + local dx, dy = aim.ring[1] - hx, aim.ring[2] - hy + local d = math.sqrt(dx * dx + dy * dy) + dx, dy = dx / d, dy / d + U.log(("aim: hand %.0f,%.0f ring %.0f,%.0f outer %.0f") + :format(hx, hy, aim.ring[1], aim.ring[2], aim.outer)) + local step = SPEED / 60 + if love.mousepressed then + local px, py = toWin(hx, hy) + love.mousepressed(px, py, 1, false, 1) + end + for i = 1, FRAMES do + local wx, wy = toWin(hx + dx * step * i, hy + dy * step * i) + if love.mousemoved then love.mousemoved(wx, wy, 0, 0, false) end + U.wait(1) + end + if love.mousereleased then + local wx, wy = toWin(hx + dx * step * FRAMES, hy + dy * step * FRAMES) + love.mousereleased(wx, wy, 1, false, 1) + end + -- what actually left the hand, before gravity has touched it + local s0 = CatchThrow.session() + if s0 and s0.vel then + local p = s0.ballInst.pos + U.log(("LAUNCH fwd/up/lat = %.1f %.1f %.1f from height %.1f, %.1f px out") + :format(math.sqrt(s0.vel[1] ^ 2 + s0.vel[3] ^ 2), s0.vel[2], 0, + p[2] - s0.groundY, + math.sqrt((s0.enemyPos[1] - p[1]) ^ 2 + + (s0.enemyPos[3] - p[3]) ^ 2))) + else + U.log("LAUNCH -- nothing was thrown (flick rejected)") + end + else + U.log("NO AIM INFO -- capture mode did not open") + end + + U.wait(4) + -- did it CONNECT? poll the flight out and say plainly which way it + -- went, so a strength sweep reads as a table instead of a guess + do + local verdict, peak = "no throw", 0 + for _ = 1, 200 do + local s = CatchThrow.session() + if not s then verdict = "session gone" break end + if s.phase == "flight" then + local h = s.ballInst.pos[2] - s.groundY + if h > peak then peak = h end + verdict = "MISS (fell short / wide)" + elseif s.phase == "suck" or s.phase == "drop" + or s.phase == "wobble" then + verdict = "HIT" .. (s.tier and (" " .. s.tier) or " (outside ring)") + break + elseif s.phase ~= "aim" then + break + end + U.wait(1) + end + local body = CatchThrow.session() and CatchThrow.session().body + U.log(("THROW swipe=%d -> %s (apex %.1f world px, body centre %.1f)") + :format(SPEED_USED or -1, verdict, peak, body and body.yOff or -1)) + end + U.log("after flick: " .. phase()) + U.shot(game, DIR .. "/2_flight.png") + + -- the suck: ball open at the foe, beam on, mon shrinking + for _ = 1, 60 do + U.wait(1) + local s = CatchThrow.session() + if s and s.phase == "suck" then break end + if not CatchThrow.session() then break end + end + U.log("suck check: " .. phase() + .. " shrink=" .. tostring(BattleScene.capture + and BattleScene.capture.shrink)) + U.shot(game, DIR .. "/3_suck.png") + + -- the drop and the first wobble + for _ = 1, 120 do + U.wait(1) + local s = CatchThrow.session() + if s and s.phase == "wobble" then break end + if not s then break end + end + U.wait(30) + U.log("wobble check: " .. phase() + .. " shrink=" .. tostring(BattleScene.capture + and BattleScene.capture.shrink)) + U.shot(game, DIR .. "/4_wobble.png") + + -- the outcome: stars or burst, then the engine's own text + for _ = 1, 300 do + U.wait(1) + local s = CatchThrow.session() + if not s or s.phase == "epilogue" or s.phase == "burst" then break end + end + U.wait(20) + U.log("outcome: " .. phase()) + U.shot(game, DIR .. "/5_outcome.png") + + -- under FULL a breakout must land straight back in throw mode -- no + -- enemy turn between; a catch plays out its epilogue instead + -- keep tapping through the caught chatter so storeCaughtMon (and the + -- experience payout hanging off it) actually runs + for _ = 1, 60 do U.tap(game, "a"); U.wait(6) end + U.log(("after outcome: %s battle=%s balls=%d") + :format(phase(), tostring(battle.phase), + game.save.inventory.POKE_BALL or 0)) + for i, m in ipairs(game.save.party) do + local was = expBefore[i] + local nm = game.data.pokemon[m.species].name + U.log(("EXP %-10s Lv%-3d -> Lv%-3d exp %d -> %d (+%d)") + :format(nm, was.level, m.level, was.exp, m.exp, m.exp - was.exp)) + end + local combo = lib.require("LetsGo").combo() + U.log("combo: " .. (combo and (combo.species .. " x" .. combo.count) + or "none")) + U.shot(game, DIR .. "/6_after.png") + U.log("done -- " .. DIR) +end diff --git a/tests/letsgo_trainer_exp.lua b/tests/letsgo_trainer_exp.lua new file mode 100644 index 0000000..e344d91 --- /dev/null +++ b/tests/letsgo_trainer_exp.lua @@ -0,0 +1,115 @@ +-- Driver: does a TRAINER knockout pay the whole party under LET'S GO FULL? +-- +-- The catch payout is easy to see (one throw, one award). A knockout is +-- not: it goes through the engine's own faint -> awardExp path, which is +-- the one this mode replaces wholesale. So fight a real trainer with a +-- deliberately uneven party and read the deltas -- every member should +-- gain, and the low ones should gain multiples of the high one. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_trainer_exp.lua \ +-- SHOT_DIR=.scratchpad/letsgo "/c/Program Files/LOVE/lovec.exe" . +-- +-- DS_LETSGO_MODE=off runs the same fight on the engine's own rules, which +-- is the control: there, only the Pokemon that fought should gain. +return function(game) + local U = dofile("tests/drivers/util.lua") + local MODE = os.getenv("DS_LETSGO_MODE") or "full" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + return + end + local LetsGo = lib.require("LetsGo") + LetsGo.setting:setValue(MODE == "off" and false or MODE, game) + U.log("LET'S GO mode: " .. tostring(LetsGo.mode())) + + -- one strong fighter and two bystanders: only the fighter gains on the + -- engine's own rules, so the bystanders ARE the test + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 45), + Pokemon.new(game.data, "PIKACHU", 10), + Pokemon.new(game.data, "RATTATA", 5), + } + game.save.player.name = "RED" + + -- The WEAKEST party in the game: one Pokemon, lowest level. Picked by + -- measuring rather than by name -- an alphabetical first pick lands on + -- OPP_AGATHA, whose Elite Four ghosts a level 45 Charizard does not + -- reliably clear, and a fight that never resolves reads exactly like a + -- payout that never happened. + local class, partyIx, best = nil, 1, nil + local ids = {} + for id in pairs(game.data.trainers) do + if type(id) == "string" and id:sub(1, 1) ~= "_" then ids[#ids + 1] = id end + end + table.sort(ids) -- stable across runs + for _, id in ipairs(ids) do + local rec = game.data.trainers[id] + for pi, party in ipairs((type(rec) == "table" and rec.parties) or {}) do + local n, top = 0, 0 + for _, mon in ipairs(party) do + n = n + 1 + top = math.max(top, tonumber(mon.level) or 0) + end + if n > 0 then + local score = n * 100 + top + if not best or score < best then + best, class, partyIx = score, id, pi + end + end + end + end + U.log(("fighting %s party %d (weakest of %d classes)") + :format(tostring(class), partyIx, #ids)) + + local before = {} + for i, m in ipairs(game.save.party) do + before[i] = { exp = m.exp, level = m.level } + end + + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(60) + + local battle = BattleState.newTrainer(game, class, partyIx) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + U.wait(70) + + -- through the intro to the menu, then FIGHT + first move, over and + -- over until the fight resolves. The enemy's HP is logged as it goes, + -- so a fight that stalls is visibly a stall rather than a silent zero. + local lastHP = nil + for i = 1, 900 do + if battle.result then break end + if battle.phase == "menu" then + battle.menuIndex = 1 -- FIGHT + U.tap(game, "a") + elseif battle.phase == "moveSelect" then + battle.moveIndex = 1 + U.tap(game, "a") + else + U.tap(game, "a") + end + local hp = battle.enemy and battle.enemy.mon and battle.enemy.mon.hp + if hp ~= lastHP then + lastHP = hp + U.log((" foe HP -> %s (phase %s, step %d)") + :format(tostring(hp), tostring(battle.phase), i)) + end + U.wait(5) + end + U.log("battle result: " .. tostring(battle.result) + .. " phase=" .. tostring(battle.phase)) + + for i, m in ipairs(game.save.party) do + local was = before[i] + U.log(("EXP %-10s Lv%-3d -> Lv%-3d exp %d -> %d (+%d)") + :format(game.data.pokemon[m.species].name, was.level, m.level, + was.exp, m.exp, m.exp - was.exp)) + end + U.log("done") +end