Compare commits

..

9 Commits

Author SHA1 Message Date
DramaticShape 377b0f60ca Merge pull request #141 from DramaticShape/dev
Fix ios Battle Hud compositing
2026-08-07 21:23:09 -04:00
DramaticShape b285201cba Merge pull request #140 from DramaticShape/dev
1.7.1
2026-08-07 21:06:18 -04:00
DramaticShape e14afd8dfa Merge pull request #131 from DramaticShape/dev
1.7.0
2026-08-06 20:43:35 -04:00
DramaticShape 6bcd4d3144 Merge pull request #118 from DramaticShape/dev
1.6.2
2026-08-06 01:17:21 -04:00
DramaticShape 790c34efff Merge pull request #115 from DramaticShape/dev
1.6.1
2026-08-05 19:11:33 -04:00
DramaticShape 43385c8bf6 Merge pull request #99 from DramaticShape/dev
1.6.0
2026-08-04 17:51:30 -04:00
DramaticShape c404c766cd Merge pull request #89 from DramaticShape/dev
1.5.5
2026-08-03 17:54:01 -04:00
DramaticShape 9700806a92 Merge pull request #72 from DramaticShape/dev
1.5.4
2026-08-02 22:44:57 -04:00
DramaticShape 6e9b787e41 Merge pull request #70 from DramaticShape/dev
ios fixes
2026-08-02 21:16:15 -04:00
15 changed files with 1550 additions and 5207 deletions
-50
View File
@@ -1,55 +1,5 @@
# 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
+16 -105
View File
@@ -48,29 +48,6 @@ 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
@@ -218,29 +195,14 @@ 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
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 }
out[#out + 1] = { tex = tex.canvas,
model = monMatrix(tex, cell[1], groundY, cell[2],
mirror) }
end
end
return out
@@ -351,14 +313,6 @@ 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
@@ -423,10 +377,6 @@ 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
@@ -570,18 +520,7 @@ function BattleScene.render(state, arena, textures, token)
end
local groundY = BattleScene.groundY(host, arena)
-- 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
local cam, pitch = BattleCam.rig(arena, groundY)
cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
local cx, cy = arena.mid[1], arena.mid[2]
@@ -590,8 +529,7 @@ 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 = (capFrameH or BattleCam.frameH(arena)) * ph
/ (BattleScene.GB_H * s)
local vh = 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
@@ -724,11 +662,6 @@ 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
@@ -762,47 +695,25 @@ 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 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
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
out = {
canvas = canvas,
player = { pmx, pmy },
enemy = { emx, emy },
playerSpan = pSpan,
enemySpan = eSpan,
playerSpan = math.abs(pr - pl),
enemySpan = math.abs(er - el),
-- 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
-1605
View File
File diff suppressed because it is too large Load Diff
-98
View File
@@ -1,98 +0,0 @@
-- LET'S GO: the whole party's experience, on one card.
--
-- Sharing experience to everybody has a cost the original game never had
-- to pay: six Pokemon means six "X gained N EXP. Points!" boxes for every
-- knockout, each needing its own press. That is the same information the
-- player wanted, delivered in the most tiring possible way -- and it is
-- worse than a wall of text, because the numbers arrive one at a time so
-- the one thing a shared payout is FOR (comparing them: the little one
-- gained five times what the big one did) can never be seen at once.
--
-- So the per-Pokemon lines are suppressed and this card is shown in their
-- place: one box, one press, every gain side by side, with a level-up
-- called out on the row it happened to. What the card cannot cover still
-- plays as it always did -- "X grew to level 6!", the stats window, and
-- any move learned -- because those are events, not a tally.
--
-- Drawn with the engine's own font and box, in the GB's own frame, so it
-- sits in a staged 3D battle exactly like every other battle panel.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ExpPanel = {}
ExpPanel.__index = ExpPanel
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
-- the GB frame, in 8-pixel tiles
local TILE = 8
local COLS, ROWS = 20, 18
local ROW_H = 12 -- pixels between listed Pokemon
-- `rows` is filled by the award loop and read HERE, at draw time: the
-- loop runs to completion long before the battle queue reaches this
-- panel, so the table is always complete by the time it is shown.
function ExpPanel.new(game, rows)
return setmetatable({ game = game, rows = rows or {}, t = 0 }, ExpPanel)
end
function ExpPanel:update(dt)
self.t = (self.t or 0) + (dt or 0)
local input = self.game and self.game.input
if not input then return end
-- a beat of deafness: the press that dismissed whatever came before
-- must not dismiss this card in the same breath
if self.t < 0.12 then return end
if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
function ExpPanel:draw()
local F = font()
if not F then return end
local rows = self.rows or {}
local n = #rows
if n == 0 then return end
-- bottom-anchored and only as tall as it needs to be, so a two-Pokemon
-- party does not black out the fight behind it
local th = 3 + math.ceil(n * ROW_H / TILE)
th = math.min(th, ROWS - 1)
local ty = ROWS - th
F.drawBox(0, ty, COLS, th)
love.graphics.setColor(0, 0, 0, 1)
local x0 = TILE
local y0 = (ty + 1) * TILE + 2
F.draw("EXP GAINED", x0, y0)
for i, r in ipairs(rows) do
local y = y0 + ROW_H + (i - 1) * ROW_H
if y > (ROWS - 1) * TILE then break end
local mon = r.mon
local name = mon.nickname
or (self.game.data.pokemon[mon.species] or {}).name
or tostring(mon.species)
F.draw(name, x0, y)
-- a level-up is called out where it happened rather than left to the
-- message that follows, so the card reads as the whole story
if (r.to or 0) > (r.from or 0) then
local up = ("L%d"):format(r.to)
F.draw(up, 108 - F.width(up), y)
end
local amt = ("+%d"):format(r.gained or 0)
F.draw(amt, 152 - F.width(amt), y)
end
love.graphics.setColor(1, 1, 1, 1)
end
return ExpPanel
-426
View File
@@ -1,426 +0,0 @@
-- 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.
-- ------- and it is announced ONCE, not once per Pokemon
--
-- Six party members would otherwise mean six "X gained N EXP. Points!"
-- boxes per knockout. The per-Pokemon lines are suppressed (the `false`
-- to applyShare) and one card is shown instead -- see lib/ExpPanel.lua
-- for why that is better than a faster wall of the same text.
--
-- The card is queued BEFORE the loop that fills it. That is not a race:
-- applyShare applies its experience immediately and only QUEUES its
-- messages, so the loop runs to completion synchronously here, while
-- the queue does not reach the card's factory until later -- by which
-- time `rows` is complete. Queueing it first is what puts the tally
-- ahead of the "grew to level" chatter it is a summary of.
local ExpPanel = V.require("ExpPanel")
local function payParty(ctx, mult)
local battle = ctx.battle
local rows = {}
battle:uiNext(function() return ExpPanel.new(battle.game, rows) end)
granting = { mult = mult }
local okAward, err = pcall(function()
for _, mon in ipairs(battle.game.save.party) do
if mon.hp > 0 then
local exp0, lv0 = mon.exp, mon.level
ctx.applyShare(mon, 1, false)
rows[#rows + 1] = { mon = mon, gained = mon.exp - exp0,
from = lv0, to = mon.level }
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,14 +64,6 @@ 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
+1527 -1582
View File
File diff suppressed because it is too large Load Diff
-594
View File
@@ -1,594 +0,0 @@
-- 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,13 +391,6 @@ 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
@@ -432,16 +425,6 @@ 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]
@@ -508,34 +491,6 @@ 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
-49
View File
@@ -456,55 +456,6 @@ 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
-30
View File
@@ -728,36 +728,6 @@ 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
-44
View File
@@ -104,11 +104,6 @@ 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.
@@ -201,19 +196,6 @@ 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.
@@ -327,7 +309,6 @@ 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,
})
@@ -518,22 +499,6 @@ 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 "
@@ -1171,15 +1136,6 @@ 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
+7 -99
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
-- on every platform, saying WHERE? rather than IMPORT where there is no file
-- dialog to open
T.eq(#hookedRows, 13, "the options hook added a row per setting, plus the "
T.eq(#hookedRows, 12, "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[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
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
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,9 +446,6 @@ 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 = {} } }
@@ -527,7 +524,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[11]
local aa = hookedRows[10]
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 "
@@ -6278,95 +6275,6 @@ 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()
-309
View File
@@ -1,309 +0,0 @@
-- 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
-163
View File
@@ -1,163 +0,0 @@
-- 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()))
-- count the summary cards at their SOURCE rather than by catching them
-- on the stack: the driver taps fast enough to dismiss one between two
-- polls, and an absence there would look like a card that never came
local ExpPanel = lib.require("ExpPanel")
local innerNew, cards = ExpPanel.new, 0
ExpPanel.new = function(g, rows)
local panel = innerNew(g, rows)
cards = cards + 1
-- read at DRAW time in the real thing; here the loop that fills it
-- has already run, so peeking now is honest
local parts = {}
for _, r in ipairs(rows or {}) do
parts[#parts + 1] = ("%s+%d%s"):format(
(g.data.pokemon[r.mon.species] or {}).name or "?", r.gained or 0,
(r.to or 0) > (r.from or 0) and ("->L" .. r.to) or "")
end
U.log(("EXP CARD #%d: %s"):format(cards, table.concat(parts, " ")))
CARD_HOLD = 20 -- frames to stop tapping, so it can be shot
return panel
end
-- 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, shotPanel = nil, false
for i = 1, 900 do
-- the fight ending does NOT end the loop until the card has been
-- photographed: the last knockout resolves the battle in the same
-- breath that queues the card, so breaking on `result` alone leaves
-- every run with the card built and never seen
if battle.result and shotPanel then break end
-- hold the taps while the card is up, so it can be shot rather than
-- dismissed on the next frame
if (CARD_HOLD or 0) > 0 then
CARD_HOLD = CARD_HOLD - 1
if not shotPanel then
local top = game.stack and game.stack:top()
if top and rawget(top, "rows") then
shotPanel = true
U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad")
.. "/exp_panel.png")
U.log("shot the card")
end
end
U.wait(1)
elseif 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
-- photograph the summary card the moment it is on top, then let the
-- taps carry on and dismiss it
local top = game.stack and game.stack:top()
if top and not shotPanel and top ~= battle and rawget(top, "rows") then
shotPanel = true
U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad")
.. "/exp_panel.png")
U.log("shot the card")
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