This commit is contained in:
DramaticShape
2026-08-08 09:06:51 -04:00
parent 063ce6e328
commit f0d5ca570a
14 changed files with 5039 additions and 1550 deletions
+50
View File
@@ -1,5 +1,55 @@
# Changelog # 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 ## 1.7.1
### Added ### Added
+105 -16
View File
@@ -48,6 +48,29 @@ local Map = require("src.world.Map")
local BattleScene = {} 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 -- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
-- is solved against. -- is solved against.
BattleScene.GB_W = 160 BattleScene.GB_W = 160
@@ -195,14 +218,29 @@ end
local function monCards(arena, groundY, textures) local function monCards(arena, groundY, textures)
local out = {} local out = {}
if not textures then return out end if not textures then return out end
local cap = BattleScene.capture
for _, side in ipairs({ "enemy", "player" }) do for _, side in ipairs({ "enemy", "player" }) do
local tex = textures[side] local tex = textures[side]
local cell = (side == "player") and arena.player or arena.enemy 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 if tex and tex.canvas and cell then
local mirror = (side == "player") and not tex.trainer local mirror = (side == "player") and not tex.trainer
out[#out + 1] = { tex = tex.canvas, local model = monMatrix(tex, cell[1], groundY, cell[2], mirror)
model = monMatrix(tex, cell[1], groundY, cell[2], -- the foe drinking into the ball: scaled about its own chest, in
mirror) } -- 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
end end
return out return out
@@ -313,6 +351,14 @@ local function shadowSignature(state, arena, terrain, nbMesh, token)
-- from somewhere new must be re-cast from there -- from somewhere new must be re-cast from there
math.floor(ShadowMap.KX * 128), math.floor(ShadowMap.KX * 128),
math.floor(ShadowMap.KZ * 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 for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
return table.concat(parts, ",") return table.concat(parts, ",")
end 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 -- 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. -- rooted to the ground plane, and a model has thickness of its own.
pcall(function() V.require("Stadium").cast(ShadowMap) end) 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) ShadowMap.finish(sig)
end end
@@ -520,7 +570,18 @@ function BattleScene.render(state, arena, textures, token)
end end
local groundY = BattleScene.groundY(host, arena) 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) cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
local cx, cy = arena.mid[1], arena.mid[2] 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 -- 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 -- 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 -- 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 local vw = vh * pw / ph
-- the cards need the camera's eye to face it, so the rig has to be live -- 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) V.require("Stadium").draw(BattleBillboard.PULL)
end) end)
if not okStadium then V.require("Stadium").report(stadiumErr) 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 if flashing then Voxel3D.flatten(nil) end
-- grass and flowers ride the same camera-ward pull the free-roam pass -- grass and flowers ride the same camera-ward pull the free-roam pass
-- gives them, measured against THIS camera's pitch rather than the -- 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 -- 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 -- 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. -- 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 half = BattleScene.CELL / 2
local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY, local function cellSpan(wx, wz)
arena.player[2], lx, ly, s, pw, ph) local x1, y1 = BattleScene.toGB(vp, wx - half, groundY, wz,
local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY, lx, ly, s, pw, ph)
arena.player[2], lx, ly, s, pw, ph) local x2, y2 = BattleScene.toGB(vp, wx + half, groundY, wz,
local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY, lx, ly, s, pw, ph)
arena.enemy[2], lx, ly, s, pw, ph) local x3, y3 = BattleScene.toGB(vp, wx, groundY, wz - half,
local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY, lx, ly, s, pw, ph)
arena.enemy[2], lx, ly, s, pw, ph) local x4, y4 = BattleScene.toGB(vp, wx, groundY, wz + half,
if not (pl and pr and el and er) then return end 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 = { out = {
canvas = canvas, canvas = canvas,
player = { pmx, pmy }, player = { pmx, pmy },
enemy = { emx, emy }, enemy = { emx, emy },
playerSpan = math.abs(pr - pl), playerSpan = pSpan,
enemySpan = math.abs(er - el), enemySpan = eSpan,
-- the letterbox, so the depth-of-field pass can put its sharp band on -- 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 -- the two marks rather than on a fraction of the window
lx = lx, ly = ly, scale = s, pw = pw, ph = ph, 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 -- 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 -- geometry and so never went past the shader that applied it -- the back
-- pic pinned to the menu (see OverworldBattle.backPinned). Neutral -- pic pinned to the menu (see OverworldBattle.backPinned). Neutral
+1605
View File
File diff suppressed because it is too large Load Diff
+404
View File
@@ -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
+8
View File
@@ -64,6 +64,14 @@ function Mat4.rotateX(a)
0, 0, 0, 1 } 0, 0, 0, 1 }
end 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 -- The rotation a unit quaternion describes, row-major. The VR rig is what
-- needs it: an OpenXR eye pose arrives as position + orientation -- needs it: an OpenXR eye pose arrives as position + orientation
-- quaternion, and both the eye's transform and its inverse (the view) are -- quaternion, and both the eye's transform and its inverse (the view) are
+1582 -1527
View File
File diff suppressed because it is too large Load Diff
+594
View File
@@ -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
+45
View File
@@ -391,6 +391,13 @@ function Stadium.update(dt, battle, groundY)
if mon.species then StadiumPack.keep(mon.species) end if mon.species then StadiumPack.keep(mon.species) end
mon.visible = (mon.rig ~= nil) and onField(battle, side, mon) mon.visible = (mon.rig ~= nil) and onField(battle, side, mon)
and not (battler and battler.substituteHP) 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 -- cleared up front, so a side that has just lost its rig cannot leave
-- last frame's matrix behind it -- last frame's matrix behind it
mon.model_matrix = nil mon.model_matrix = nil
@@ -425,6 +432,16 @@ function Stadium.update(dt, battle, groundY)
local okG, grow = pcall(battle.growInScale, battle, battler) local okG, grow = pcall(battle.growInScale, battle, battler)
mon.scale = (okG and grow) or 1 mon.scale = (okG and grow) or 1
end 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) mon:update(dt or 0)
if mon.visible and arena then if mon.visible and arena then
local cell = arena[side] local cell = arena[side]
@@ -491,6 +508,34 @@ function Stadium.guard(side, mon, what, fn)
return false return false
end 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) function Stadium.draw(pull)
if not session then return end if not session then return end
for _, side in ipairs({ "enemy", "player" }) do for _, side in ipairs({ "enemy", "player" }) do
+49
View File
@@ -456,6 +456,55 @@ function StadiumMon:matrix(x, groundY, z, faceX, faceZ)
Mat4.translate(0, -lift, 0)) Mat4.translate(0, -lift, 0))
end 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 -- 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 -- 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 -- skinned mesh, and skinning it once is the whole reason this is worth
+30
View File
@@ -728,6 +728,36 @@ function StadiumRig:skin(yaw)
end end
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 -- ------- which texture each part wears this frame
-- --
-- The eyes. A primitive whose display list carried geo command 0x23 with a -- The eyes. A primitive whose display list carried geo command 0x23 with a
+44
View File
@@ -104,6 +104,11 @@ local Horde = V.require("Horde")
local HordeGun = V.require("HordeGun") local HordeGun = V.require("HordeGun")
local HordeHud = V.require("HordeHud") local HordeHud = V.require("HordeHud")
local HordeSfx = V.require("HordeSfx") 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) -- Forward declaration: the voxel pipeline's update hook (registered below)
-- calls this, and it is defined further down with the settings it drives. -- 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 -- the atmosphere's own clock (shaft shimmer, drifting motes), on the
-- same tick so the beams keep breathing through a dialog box -- same tick so the beams keep breathing through a dialog box
ForestAtmos.update(dt) 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 -- 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 -- 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. -- 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 ChunkMesher.invalidate() -- no map id = every cached mesh
ForestAtmos.invalidate() -- shaft/particle meshes and shader sentinels ForestAtmos.invalidate() -- shaft/particle meshes and shader sentinels
VR.invalidate() -- the mirror, and FBO ids of dead canvases VR.invalidate() -- the mirror, and FBO ids of dead canvases
Pokeball.invalidate() -- the ball's meshes and palette texture
end, end,
}) })
@@ -499,6 +518,22 @@ local SETTINGS = {
.. "The foe is still out there on its own tile.", .. "The foe is still out there on its own tile.",
when = function() return stagedBattles() and not VR.enabled() end, when = function() return stagedBattles() and not VR.enabled() end,
full = true }, 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, { DayNight.setting,
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " "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 " .. "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. -- become the same eight buttons. See lib/Horde.lua.
Horde.install() 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 -- ------- edge-anchored menus stay in the GB frame while a headset is live
-- --
-- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START -- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START
+99 -7
View File
@@ -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 -- (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 -- on every platform, saying WHERE? rather than IMPORT where there is no file
-- dialog to open -- 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") .. "STADIUM ROM action row")
local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5] local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5]
local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[9] local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[10]
-- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6] and AA -- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6], LET'S GO
-- hookedRows[10]; all three are read where they are used rather than named -- hookedRows[9] and AA hookedRows[11]; all four are read where they are
-- here, because this chunk is one main function and has 200 local slots to -- used rather than named here, because this chunk is one main function and
-- spend -- has 200 local slots to spend
T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label") T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label")
T.eq(hookedRows[4].value(), "FIT", T.eq(hookedRows[4].value(), "FIT",
"and defaults to FIT -- the cut IS the window the flat game already " "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") .. "map, so the classic slot is opt-in")
T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true), 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") "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 -- stepping writes through to the one place both rows read
local settingGame = { save = { options = {} }, mods = { modOptions = {} } } local settingGame = { save = { options = {} }, mods = { modOptions = {} } }
@@ -524,7 +527,7 @@ do
local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias") local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
local aaGame = { save = { options = {} }, mods = { modOptions = {} } } 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.label, "AA", "the anti-aliasing row carries its label")
T.eq(aa.value(), "OFF", T.eq(aa.value(), "OFF",
"and starts off -- supersampling is a cost knob, and a mod must not spend " "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 VS.angle = angleWas
end)() 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() Pipelines.reset()
run.release() run.release()
+309
View File
@@ -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
+115
View File
@@ -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