mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:50:50 +02:00
lets go
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+404
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user