Compare commits

..

13 Commits

Author SHA1 Message Date
DramaticShape 87a6c03017 add ball throw mechanic 2026-08-08 09:44:47 -04:00
DramaticShape f0d5ca570a lets go 2026-08-08 09:06:51 -04:00
DramaticShape 063ce6e328 Bump version from 1.7.1 to 1.7.2 2026-08-07 21:20:47 -04:00
DramaticShape c9c5a8901a Merge pull request #119 from Elementalzx14/fix/ios-battle-hud
Fix iOS battle HUD
2026-08-07 21:16:31 -04:00
DramaticShape d86d387243 Merge pull request #139 from DramaticShape/stadium_fixes
1.7.1
2026-08-07 21:05:19 -04:00
DramaticShape 4ee15c4f32 Update README to include redistribution policy
Added a restriction on code redistribution after v1.6.0.
2026-08-07 19:03:37 -04:00
DramaticShape 301bd4c1f9 Merge pull request #130 from DramaticShape/stadium_fixes
Stadium fixes
2026-08-06 20:40:38 -04:00
DramaticShape 00f2b0bb9a Merge pull request #128 from DramaticShape/stadium_fixes
Stadium fixes
2026-08-06 18:24:30 -04:00
Alonso Avitia 0d0ec51f51 Fix iOS battle HUD compositing 2026-08-06 00:27:52 -07:00
DramaticShape b50bbe0782 Merge pull request #111 from Code-Grub/fix/roof-surface-silhouette-cap
keep a sloped roof's surface off its own outline cap
2026-08-06 01:13:19 -04:00
DramaticShape 9f74ea7e73 Merge pull request #117 from DramaticShape/stadium_fixes
Update viridan forest FX
2026-08-06 01:12:06 -04:00
DramaticShape e14cf3de90 Merge pull request #114 from DramaticShape/stadium_fixes
Stadium fixes
2026-08-05 19:10:17 -04:00
Code-Grub dde0879527 keep a sloped roof's surface off its own outline cap
The hip ends of the gabled house and Oak's lab come out as black teeth
marching down the slope instead of battens running with it.

The depth->row map is authored for columns the drawing paints edge to
edge. A tapered column starts further down the band, and the surface was
clamped into its first DRAWN row to stay inside the silhouette -- but a
column's first drawn row is its silhouette CAP, and the cap is outline
black. On a tapered column the map spends most of the roof's depth above
that cap (seven of the gabled house's fifty depth voxels land on it
outright), so the clamp painted one outline pixel the length of the
slope and roofCycle beat against it: black punctuated by the real batten
colour every 4 rows on the house, every 8 on the lab.

Lift to the column's first PAINTED row instead -- the same refusal to
let outline black stand as a face that measure() already makes for the
side faces, which the roof band was never given.

Geometry is untouched: voxel, shell and recess counts are identical for
all 50 templates. verify_roof now asserts no roof surface samples its
column's cap, and tests/roof_cap_shots.lua shoots the two Pallet Town
drawings A/B.

The version bump and CHANGELOG entry are left out deliberately, so this
does not collide with the release flow.
2026-08-05 17:45:56 -04:00
20 changed files with 3884 additions and 73 deletions
+50
View File
@@ -1,5 +1,55 @@
# Changelog
## 1.8.0
### Added
- **LET'S GO: Pokemon GO-style catching, staged in the 3D battle.** A new
three-rung row. CATCH ONLY changes nothing about the game except the
throw: picking a Poke/Great/Ultra/Master Ball in a wild battle (or the
BALL row of the safari menu) opens capture mode instead of the automatic
toss. FULL makes wild encounters the real Let's Go article: the
encounter IS the catch -- it opens in throwing mode and stays there,
the foe never takes a turn, your own Pokemon is never sent out or
shown (no back pic, no model, no HUD), and B runs, which from a catch
encounter always works. Poke/Great/Ultra Balls are half price at every
mart, and EXPERIENCE works the way that game's does: every healthy
party member gains from every catch AND every trainer knockout, each
one measured against its OWN level through the Gen VII scaled formula
-- which is why Let's Go ships no EXP.ALL, and why a level 5 party
member takes several times what a level 45 one does from the very same
fight. A catch adds the throw stack on top: grade, first ball of the
encounter, new species, and a persistent catch combo. CATCH ONLY
leaves experience exactly as the original game had it.
**The throw.** The camera locks HEAD ON with the wild Pokemon -- its
own seat on the arena's axis, no drift, no steer -- and a real 3D Poke
Ball (modelled and animated for this: hinged lid, capture beam,
squash-click, decaying wobble, caught stars, breakout burst; GREAT
blue, ULTRA's yellow band, MASTER purple, SAFARI olive) hangs at the
bottom of the frame. The ball rides UNDER the finger -- mouse, touch,
or the right stick -- and releasing throws it with the swipe's own
velocity: forward from how hard, height from its rise, side from its
slant, gravity and collision deciding the rest, with a bearing-and-
range assist trimming honest errors. Circling the ball WINDS it -- the
spin visibly builds with the gesture to a cap and bleeds off when the
hand pauses -- and only a ball at the cap flies with the late-biting
curve. Contact is against the creature's own GEOMETRY: a pic foe is
its sprite's opaque pixels (a ball through the gap under a wing flies
on), a STADIUM foe its model's measured height, girth and hover. The
timing ring pulses on the creature, coloured by the Gen 1 odds, and is
judged AT the moment of contact: inside earns NICE / GREAT /
EXCELLENT, which multiplies the engine's own Gen 1 catch roll; the
shakes the roll answers are the rocks the ball plays on the ground.
**What it stands on.** The outcome is exactly a Gen 1 ball throw: same
catch math (status, HP and ball factors intact), same outcome texts,
same caught flow -- dex page, nickname, box overflow -- and a missed
ball is a spent ball. Outside FULL, a failed throw still costs the
turn it always did. Needs the staged 3D battle standing (3D-BTL on, a
depth-capable driver, no headset); anywhere it cannot stand, balls
quietly take the engine's classic toss.
## 1.7.1
### Added
+3 -1
View File
@@ -1,5 +1,7 @@
# Dramatic Shape Voxel Mod
Redistribution of non-derivative code is expressly prohibited after v1.6.0 without permission.
A mod for the [Pokémon Gen 1 Recompilation
Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project).
@@ -293,4 +295,4 @@ from upstream and follow that project's own terms.
No Pokémon Stadium ROM data ships here either. The models are built on the
player's own machine, from a cartridge they supply, into their own save
directory — see [Getting the models](#getting-the-models).
directory — see [Getting the models](#getting-the-models).
+105 -16
View File
@@ -48,6 +48,29 @@ local Map = require("src.world.Map")
local BattleScene = {}
-- ------- LET'S GO capture mode's stake in this scene
--
-- One table while a capture session runs, nil otherwise (see
-- lib/CatchThrow.lua, which owns it):
--
-- hidePlayer the player's side stays out of the shot entirely -- no
-- card here, no model (Stadium reads this same table), no
-- pinned back pic (OverworldBattle reads it too)
-- shrink the foe's scale while the ball drinks it in, applied
-- about its chest so it collapses toward the beam
-- draw(pull) the Poke Ball, drawn after the Stadium models -- same
-- depth buffer, same flash window, same camera
-- cast(sm) the same ball into the sun's pass
-- sig() a term for the cached shadow signature, so a ball in
-- flight re-casts and a resting scene does not
-- drawGB(b) the 2D layer (ring, labels), drawn by OverworldBattle's
-- BattleState:draw wrap in the GB frame
--
-- It lives HERE, not on OverworldBattle, because every consumer below
-- already requires BattleScene and the one file that writes it requires
-- both -- this is the spot with no require cycle.
BattleScene.capture = nil
-- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
-- is solved against.
BattleScene.GB_W = 160
@@ -195,14 +218,29 @@ end
local function monCards(arena, groundY, textures)
local out = {}
if not textures then return out end
local cap = BattleScene.capture
for _, side in ipairs({ "enemy", "player" }) do
local tex = textures[side]
local cell = (side == "player") and arena.player or arena.enemy
-- capture mode: the player's side is out of the shot (the seat looks
-- over an empty shoulder), and OverworldBattle.textures already
-- skipped rendering it -- this is the belt to that suspender
if side == "player" and cap and cap.hidePlayer then tex = nil end
if tex and tex.canvas and cell then
local mirror = (side == "player") and not tex.trainer
out[#out + 1] = { tex = tex.canvas,
model = monMatrix(tex, cell[1], groundY, cell[2],
mirror) }
local model = monMatrix(tex, cell[1], groundY, cell[2], mirror)
-- the foe drinking into the ball: scaled about its own chest, in
-- world space so the composed card matrix needs no decomposition
if side == "enemy" and cap and cap.shrink then
local k = cap.shrink
local ax, ay, az = cell[1], groundY + 8, cell[2]
model = Mat4.mul(
Mat4.mul(Mat4.translate(ax, ay, az),
Mat4.mul(Mat4.scale(k, k, k),
Mat4.translate(-ax, -ay, -az))),
model)
end
out[#out + 1] = { tex = tex.canvas, model = model }
end
end
return out
@@ -313,6 +351,14 @@ local function shadowSignature(state, arena, terrain, nbMesh, token)
-- from somewhere new must be re-cast from there
math.floor(ShadowMap.KX * 128),
math.floor(ShadowMap.KZ * 128) }
-- a capture session's ball moves through the sun's world too; its term
-- is quantised inside sig() so the cache re-renders on real movement
-- and not on every frame the ball rests
local cap = BattleScene.capture
if cap and cap.sig then
local okSig, sig = pcall(cap.sig)
parts[#parts + 1] = okSig and sig or "cap"
end
for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
return table.concat(parts, ",")
end
@@ -377,6 +423,10 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
-- the water. Un-snugged for the same reason: snug is a bias for a card
-- rooted to the ground plane, and a model has thickness of its own.
pcall(function() V.require("Stadium").cast(ShadowMap) end)
-- the capture session's ball, by the same reasoning: real geometry, its
-- shadow is half of what sells the arc
local cap = BattleScene.capture
if cap and cap.cast then pcall(cap.cast, ShadowMap) end
ShadowMap.finish(sig)
end
@@ -520,7 +570,18 @@ function BattleScene.render(state, arena, textures, token)
end
local groundY = BattleScene.groundY(host, arena)
local cam, pitch = BattleCam.rig(arena, groundY)
-- A capture session brings a camera of its own: the head-on seat, on
-- the arena's axis looking straight at the foe, in place of the solved
-- over-the-shoulder shot. Everything downstream -- the letterbox fov,
-- the pins, the sun, the cards yawing to the eye -- is generic over
-- whichever camera this is.
local cam, pitch, capFrameH
local cap = BattleScene.capture
if cap and cap.rig then
local okRig, c, p, fh = pcall(cap.rig, arena, groundY)
if okRig and c then cam, pitch, capFrameH = c, p or 0.15, fh end
end
if not cam then cam, pitch = BattleCam.rig(arena, groundY) end
cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
local cx, cy = arena.mid[1], arena.mid[2]
@@ -529,7 +590,8 @@ function BattleScene.render(state, arena, textures, token)
-- the player's zoom is part of this: the sun's box is fitted to what the
-- frame holds, so a shot pulled wide has to light the ground it just
-- brought into view rather than the ground the rig alone would have
local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s)
local vh = (capFrameH or BattleCam.frameH(arena)) * ph
/ (BattleScene.GB_H * s)
local vw = vh * pw / ph
-- the cards need the camera's eye to face it, so the rig has to be live
@@ -662,6 +724,11 @@ function BattleScene.render(state, arena, textures, token)
V.require("Stadium").draw(BattleBillboard.PULL)
end)
if not okStadium then V.require("Stadium").report(stadiumErr) end
-- the capture session's Poke Ball, still inside the flash window and
-- with the mons' own camera-ward pull, so a ball crossing in front of
-- a card wins the depth test the way a nearer thing should
local cap = BattleScene.capture
if cap and cap.draw then pcall(cap.draw, BattleBillboard.PULL) end
if flashing then Voxel3D.flatten(nil) end
-- grass and flowers ride the same camera-ward pull the free-roam pass
-- gives them, measured against THIS camera's pitch rather than the
@@ -695,25 +762,47 @@ function BattleScene.render(state, arena, textures, token)
-- How wide one overworld square is on screen where each mon stands, in
-- GB pixels. This is what the pics are scaled to: a mon covers its own
-- square and no more, at whatever the drift has done to the distance.
--
-- Measured along BOTH map axes and answered as the larger, as a full
-- 2D screen distance. One axis alone breaks the moment a camera looks
-- ALONG it: the capture seat stands on the arena's own axis, and on a
-- quarter-turned arena that axis is world X -- the ±X probe points
-- then project to the same pixel and the span reads zero, which
-- collapsed the ring and blew up the throw's world-per-pixel mapping.
local half = BattleScene.CELL / 2
local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY,
arena.player[2], lx, ly, s, pw, ph)
local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY,
arena.player[2], lx, ly, s, pw, ph)
local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY,
arena.enemy[2], lx, ly, s, pw, ph)
local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY,
arena.enemy[2], lx, ly, s, pw, ph)
if not (pl and pr and el and er) then return end
local function cellSpan(wx, wz)
local x1, y1 = BattleScene.toGB(vp, wx - half, groundY, wz,
lx, ly, s, pw, ph)
local x2, y2 = BattleScene.toGB(vp, wx + half, groundY, wz,
lx, ly, s, pw, ph)
local x3, y3 = BattleScene.toGB(vp, wx, groundY, wz - half,
lx, ly, s, pw, ph)
local x4, y4 = BattleScene.toGB(vp, wx, groundY, wz + half,
lx, ly, s, pw, ph)
if not (x1 and x2 and x3 and x4) then return nil end
local ew = math.sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
local ns = math.sqrt((x4 - x3) ^ 2 + (y4 - y3) ^ 2)
return math.max(ew, ns)
end
local pSpan = cellSpan(arena.player[1], arena.player[2])
local eSpan = cellSpan(arena.enemy[1], arena.enemy[2])
if not (pSpan and eSpan) then return end
out = {
canvas = canvas,
player = { pmx, pmy },
enemy = { emx, emy },
playerSpan = math.abs(pr - pl),
enemySpan = math.abs(er - el),
playerSpan = pSpan,
enemySpan = eSpan,
-- the letterbox, so the depth-of-field pass can put its sharp band on
-- the two marks rather than on a fraction of the window
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
-- the camera and its combined matrix, for anything that reasons
-- about this shot from outside the render -- the capture mode's
-- throw is solved in these (aim errors along this eye's own right
-- and forward, contact judged through this vp)
eye = { cam.eye[1], cam.eye[2], cam.eye[3] },
focus = { cam.focus[1], cam.focus[2], cam.focus[3] },
vp = vp,
-- and the hour's light, for anything drawn over this shot that is NOT
-- geometry and so never went past the shader that applied it -- the back
-- pic pinned to the menu (see OverworldBattle.backPinned). Neutral
+28 -5
View File
@@ -264,6 +264,27 @@ local function measure(sp, t)
top[x] = r
end
-- The row a column's roof SURFACE may sink to. `top[x]` is the
-- silhouette cap -- the black the drawing closes its shape with -- and
-- the depth map spends most of a tapered column's depth above it, so
-- clamping onto `top[x]` paints that one outline pixel the length of
-- the slope and the courses beat against it. The surface belongs on the
-- first PAINTED row instead: the same refusal to let the outline stand
-- as a face that the side faces already make below.
local surfaceTop = {}
for x = 0, W - 1 do
local y = top[x]
while y < roofRows and sp.inside[y * W + x]
and sp.col[y * W + x] == BLACK do
y = y + 1
end
if y < roofRows and sp.inside[y * W + x] then
surfaceTop[x] = y
else
surfaceTop[x] = top[x]
end
end
-- The drawing's own ground line: the row after the last drawn one. A
-- building ends on the black threshold row it stands on (ground == H),
-- but furniture is drawn standing on open floor -- the lab table's
@@ -386,7 +407,7 @@ local function measure(sp, t)
-- building. `depthPx` names it in voxels, for an object whose real
-- depth is not a whole tile row -- the Bike Shop toolbox is a box
-- standing in the middle of its own cell, not a thing that fills a plot.
return { top = top, ytop = ytop,
return { top = top, surfaceTop = surfaceTop, ytop = ytop,
D = t.depthPx or ((t.depth or #t.tiles) * 8),
ground = ground,
recess = recess, interior = interior, shadeTexel = shadeTexel }
@@ -971,6 +992,7 @@ local function model(sp, pr, t)
local W, H, D = sp.W, sp.H, pr.D
local slab, roofRows = t.slab, t.roofRows
local top, ytop, ground = pr.top, pr.ytop, pr.ground
local surfaceTop = pr.surfaceTop
-- The roof's drawn span. A sprite inset from its box (B03) leaves outer
-- columns undrawn in the roof band; they carry no roof at all, and the
@@ -1020,11 +1042,12 @@ local function model(sp, pr, t)
if top[x] < roofRows
and y > tx - slab and y <= tx and z >= rz0 and z <= rz1 then
if y == tx and x > x0d and x < x1d and z > rz0 and z < rz1 then
-- the surface itself. Clamping the row into the column's first
-- drawn row keeps the flank battens running down the slope
-- instead of falling off the silhouette.
-- the surface itself. Lifting the row into the column's first
-- PAINTED row keeps the flank battens running down the slope
-- instead of falling off the silhouette -- and off its cap, which
-- is outline black and belongs to the rim, not to the surface.
local sy = roofSy[z]
if sy < top[x] then sy = top[x] end
if sy < surfaceTop[x] then sy = surfaceTop[x] end
return sy * W + x
end
-- The rim reproduces the eave the drawing itself paints under the
+1605
View File
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
-- LET'S GO: the whole party's experience, on one card.
--
-- Sharing experience to everybody has a cost the original game never had
-- to pay: six Pokemon means six "X gained N EXP. Points!" boxes for every
-- knockout, each needing its own press. That is the same information the
-- player wanted, delivered in the most tiring possible way -- and it is
-- worse than a wall of text, because the numbers arrive one at a time so
-- the one thing a shared payout is FOR (comparing them: the little one
-- gained five times what the big one did) can never be seen at once.
--
-- So the per-Pokemon lines are suppressed and this card is shown in their
-- place: one box, one press, every gain side by side, with a level-up
-- called out on the row it happened to. What the card cannot cover still
-- plays as it always did -- "X grew to level 6!", the stats window, and
-- any move learned -- because those are events, not a tally.
--
-- Drawn with the engine's own font and box, in the GB's own frame, so it
-- sits in a staged 3D battle exactly like every other battle panel.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ExpPanel = {}
ExpPanel.__index = ExpPanel
local Font = nil
local function font()
if Font ~= nil then return Font or nil end
local ok, F = pcall(require, "src.render.Font")
Font = ok and F or false
return Font or nil
end
-- the GB frame, in 8-pixel tiles
local TILE = 8
local COLS, ROWS = 20, 18
local ROW_H = 12 -- pixels between listed Pokemon
-- `rows` is filled by the award loop and read HERE, at draw time: the
-- loop runs to completion long before the battle queue reaches this
-- panel, so the table is always complete by the time it is shown.
function ExpPanel.new(game, rows)
return setmetatable({ game = game, rows = rows or {}, t = 0 }, ExpPanel)
end
function ExpPanel:update(dt)
self.t = (self.t or 0) + (dt or 0)
local input = self.game and self.game.input
if not input then return end
-- a beat of deafness: the press that dismissed whatever came before
-- must not dismiss this card in the same breath
if self.t < 0.12 then return end
if input:wasPressed("a") or input:wasPressed("b") then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
function ExpPanel:draw()
local F = font()
if not F then return end
local rows = self.rows or {}
local n = #rows
if n == 0 then return end
-- bottom-anchored and only as tall as it needs to be, so a two-Pokemon
-- party does not black out the fight behind it
local th = 3 + math.ceil(n * ROW_H / TILE)
th = math.min(th, ROWS - 1)
local ty = ROWS - th
F.drawBox(0, ty, COLS, th)
love.graphics.setColor(0, 0, 0, 1)
local x0 = TILE
local y0 = (ty + 1) * TILE + 2
F.draw("EXP GAINED", x0, y0)
for i, r in ipairs(rows) do
local y = y0 + ROW_H + (i - 1) * ROW_H
if y > (ROWS - 1) * TILE then break end
local mon = r.mon
local name = mon.nickname
or (self.game.data.pokemon[mon.species] or {}).name
or tostring(mon.species)
F.draw(name, x0, y)
-- a level-up is called out where it happened rather than left to the
-- message that follows, so the card reads as the whole story
if (r.to or 0) > (r.from or 0) then
local up = ("L%d"):format(r.to)
F.draw(up, 108 - F.width(up), y)
end
local amt = ("+%d"):format(r.gained or 0)
F.draw(amt, 152 - F.width(amt), y)
end
love.graphics.setColor(1, 1, 1, 1)
end
return ExpPanel
+426
View File
@@ -0,0 +1,426 @@
-- LET'S GO: the row, the modes, and every engine seam the capture game
-- stands on.
--
-- Three rungs:
--
-- OFF nothing changes. The default, and what an unrecognised
-- stored value falls back to.
-- FULL the whole Let's Go treatment. A wild encounter opens
-- STRAIGHT into capture mode (B backs out to the classic
-- menu for anyone who came to fight), Poke/Great/Ultra
-- Balls are half price at every mart, and EXPERIENCE works
-- the way that game's does: every healthy party member
-- gains from every catch AND every trainer knockout, each
-- measured against its own level. A catch adds the throw
-- stack on top -- grade, first throw, new species, combo.
-- CATCH ONLY the fights are untouched and the shops are untouched;
-- the one change is that throwing a ball -- from the bag,
-- or a SAFARI BALL from the safari menu -- runs the throw
-- minigame instead of the automatic toss. The minigame's
-- grade still folds into the Gen 1 catch roll (a good
-- throw should matter or the ring is a lie), but nothing
-- outside the throw changes.
--
-- The capture game itself lives in lib/CatchThrow.lua and the ball it
-- throws in lib/Pokeball.lua; this file is the wiring: the ModSetting,
-- the two BattleState wraps that intercept a ball being thrown, the
-- auto-entry tick for FULL, the price patch, and the experience hooks.
--
-- ------- where the minigame declines to run
--
-- The throw is a 3D scene: it needs the staged battle standing (the
-- over-the-shoulder shot the option's own 3D-BTL row provides, ON by
-- default), a driver with a depth buffer, and a flat screen (the VR seat
-- draws through a different pass entirely). Anywhere that fails -- 3D-BTL
-- switched off, a headless driver, a headset -- the ball quietly takes
-- the engine's own toss, which is exactly what the mod's "declines
-- cleanly" rule demands. Trainers, the ghost, the RESTLESS SOUL and the
-- old man's demo keep the vanilla path on purpose: those branches ARE
-- their behaviour.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local Voxel3D = V.require("Voxel3D")
local CatchThrow = V.require("CatchThrow")
local LetsGo = {}
LetsGo.KEY = "letsgo"
LetsGo.LABEL = "LET'S GO"
-- `false` first: the default, and the fallback for a stored value from
-- some other version of this ladder
LetsGo.setting = ModSetting.new(LetsGo.KEY, LetsGo.LABEL,
{ false, "full", "catching" },
{ "OFF", "FULL", "CATCH ONLY" })
-- false | "full" | "catching"
function LetsGo.mode()
return LetsGo.setting:get()
end
local function game() return require("src.core.Game") end
-- ------- half-price balls (FULL)
--
-- Prices are live data (game.data.items[id].price) and every reader --
-- the buy list, the affordability check, the quantity box -- reads them
-- per use, so patching the table IS the feature. Applied and reverted on
-- the option's edge, polled from the tick because the row, the manager's
-- page and a loaded save can all move it and none of them announces to
-- us. The sell price follows automatically (the mart pays half of list),
-- which is coherent: cheaper balls are worth less back too.
local PRICED = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" }
local fullPrices = nil -- originals while halved, or nil
local function applyPrices()
local g = game()
local items = g and g.data and g.data.items
if not items then return end
local wantHalf = LetsGo.mode() == "full"
if wantHalf and not fullPrices then
fullPrices = {}
for _, id in ipairs(PRICED) do
local def = items[id]
if def and def.price then
fullPrices[id] = def.price
def.price = math.floor(def.price / 2)
end
end
elseif not wantHalf and fullPrices then
for id, price in pairs(fullPrices) do
if items[id] then items[id].price = price end
end
fullPrices = nil
end
end
-- ------- whether a throw can be the minigame
local function vrOn()
local ok, vr = pcall(V.require, "VR")
return ok and vr and vr.enabled and vr.enabled() or false
end
function LetsGo.wantsMinigame(battle)
if not LetsGo.mode() then return false end
if not battle or battle.kind ~= "wild" then return false end
if battle.demo or battle.ghost or battle.noCatch then return false end
if not Voxel3D.available() or vrOn() then return false end
-- the staged shot must actually be standing: this is "there is a 3D
-- battle on screen right now", which the throw is aimed into
local ok, shot = pcall(function()
return V.require("OverworldBattle").shot()
end)
return (ok and shot) and true or false
end
-- A Let's Go wild: the encounters FULL owns outright. In these the foe
-- never takes a turn, the player's Pokemon is never sent out or shown,
-- B runs (and always escapes), and the encounter lives in throw mode
-- from the wipe to the last message.
function LetsGo.fullWild(battle)
return LetsGo.mode() == "full" and battle and battle.kind == "wild"
and not (battle.safari or battle.demo or battle.ghost
or battle.noCatch)
and true or false
end
-- ------- the experience stack (FULL)
--
-- Let's Go pays a catch like a knockout, through the Gen VII scaled
-- formula -- every party member paid against its OWN level -- times the
-- catch bonuses. Three engine hooks carry it:
--
-- battle.catch_exp "does a catch pay at all" -- yes, under FULL
-- battle.exp_award the distribution: every healthy party member its
-- own full share, no participant split
-- exp.gain the amount: the scaled formula times the bonus
-- stack, in place of floor(b*L/7)
--
-- The stack: throw grade (NICE 1.1 / GREAT 1.5 / EXCELLENT 2.0), first
-- ball of the encounter 1.5, species new to the dex 1.1, and the catch
-- combo tier. Traded 1.5 still rides through the engine's own flag.
local expCtx = nil -- {battle, mult} while a Let's Go catch pays out
local granting = nil -- set across the applyShare loop for exp.gain
local function comboMult(n)
if n <= 10 then return 1.1 end
if n <= 20 then return 1.5 end
if n <= 30 then return 2.0 end
if n <= 40 then return 2.5 end
return 3.0
end
-- the catch combo, persisted with the save (mod.save rides save.modData):
-- catching the same species again extends it, anything else restarts it
local function bumpCombo(species)
local ms = V.mod and V.mod.save
local combo = { species = species, count = 1 }
if ms then
local ok, held = pcall(ms.get, ms, "letsgoCombo")
if ok and type(held) == "table" and held.species == species then
combo.count = (tonumber(held.count) or 0) + 1
end
pcall(ms.set, ms, "letsgoCombo", combo)
end
return combo.count
end
function LetsGo.combo()
local ms = V.mod and V.mod.save
if not ms then return nil end
local ok, held = pcall(ms.get, ms, "letsgoCombo")
return ok and type(held) == "table" and held or nil
end
-- Called by CatchThrow the moment a capture resolves as caught, BEFORE
-- storeCaughtMon runs -- the dex is not yet marked, so "new species" is
-- still answerable, and the exp hooks fire inside storeCaughtMon.
function LetsGo.noteCatch(battle, info)
local species = battle.enemy and battle.enemy.mon
and battle.enemy.mon.species
local chain = species and bumpCombo(species) or 1
if LetsGo.mode() ~= "full" then return end
local mult = info.mult or 1
if info.firstThrow then mult = mult * 1.5 end
local dex = game().save and game().save.pokedex
if dex and species and not dex.owned[species] then mult = mult * 1.1 end
mult = mult * comboMult(chain)
expCtx = { battle = battle, mult = mult }
end
-- The Gen VII scaled gain: a * b * L / 5, scaled by the RECEIVER's own
-- level, +1, then the traded boost and (for a catch) the bonus stack.
-- `s`, the split divisor, is 1 -- the award loop below hands every mon a
-- full share rather than a share of one.
--
-- `a` is the wild/trainer multiplier, 1.5 for a trainer's Pokemon. It is
-- absent from the catch-side write-ups of this formula for the simple
-- reason that a caught Pokemon is always wild, so it is always 1 there --
-- which is also why adding it leaves every catch payout exactly where it
-- was, verified against the published table in the suite.
local function scaledGain(c, mult)
local b = (c.defeatedDef and c.defeatedDef.baseExp) or 50
local L = c.level or 1
local Lp = (c.mon and c.mon.level) or L
local a = c.isTrainer and 1.5 or 1
local scale = ((2 * L + 10) / (L + Lp + 10)) ^ 2.5
local exp = math.floor(math.floor(a * b * L / 5) * scale + 1)
if c.traded then exp = math.floor(exp * 1.5) end
return math.max(1, math.floor(exp * (mult or 1)))
end
LetsGo._scaledGain = scaledGain -- named for the suite
LetsGo._comboMult = comboMult
-- ------- FULL's auto-entry
--
-- The moment a wild battle's menu opens under FULL, capture mode opens
-- over it, with the last ball the player threw (or the first ball in the
-- bag). B backs out to the classic menu and stays out for that battle --
-- the bag's own ball route still re-enters the throw.
local function autoEnter()
if LetsGo.mode() ~= "full" then return end
if CatchThrow.active() then return end
local ok, battle = pcall(function()
return V.require("OverworldBattle").battle()
end)
if not (ok and battle) then return end
local g = game()
if not (g.stack and g.stack:top() == battle) then return end
if battle.phase ~= "menu" then return end
if battle.safari then return end -- the safari menu is already a
-- catch menu; its BALL row enters
if battle.dramaticShapeDeclined then return end
if not LetsGo.wantsMinigame(battle) then return end
local ball = CatchThrow.pickBall()
if not ball then return end
CatchThrow.begin(battle, ball, { consumed = false, canSwitch = true,
fullWild = LetsGo.fullWild(battle) })
end
-- ------- per frame, from the voxel pipeline's update hook
--
-- BEFORE OverworldBattle.update on the same tick, so the ball pose this
-- frame computes is the ball the scene render a moment later draws.
function LetsGo.update(dt)
applyPrices()
CatchThrow.update(dt)
autoEnter()
end
-- ------- install: the two throw seams, the hooks, the input
--
-- Installed from main.lua AFTER every other input seam, so the capture's
-- pointer wraps sit outside them all while it aims.
local installed = false
function LetsGo.install()
if installed then return end
installed = true
local mod = V.mod
local BattleState = require("src.battle.BattleState")
if not BattleState.dramaticShapeLetsGoHook then
-- The bag's ball route: BagMenu has already consumed the ball and
-- closed itself when this is called, so a session here owns a paid
-- ball (cancel refunds it). Vanilla path untouched whenever the
-- minigame cannot or should not run.
local innerThrow = BattleState.throwBall
function BattleState:throwBall(ball)
if LetsGo.wantsMinigame(self) then
if CatchThrow.begin(self, ball, {
consumed = true, fullWild = LetsGo.fullWild(self),
}) then return end
end
return innerThrow(self, ball)
end
-- The safari menu's BALL row: same interception, safari flavour --
-- the ball count and the flee check belong to the safari turn, and
-- CatchThrow hands back to safariEnemyTurn on a failure.
local innerSafari = BattleState.safariAction
function BattleState:safariAction(choice)
if choice == "ball" and LetsGo.wantsMinigame(self)
and self.safari and self.safari.balls > 0 then
if CatchThrow.begin(self, "SAFARI_BALL",
{ consumed = false, safari = true }) then
return
end
end
return innerSafari(self, choice)
end
BattleState.dramaticShapeLetsGoHook = true
end
-- a catch pays experience under FULL, exactly as a knockout would
mod.hooks:wrap("battle.catch_exp", function(next_, ctx)
if LetsGo.mode() == "full" and ctx and ctx.battle
and ctx.battle.kind == "wild" then
return true
end
return next_(ctx)
end)
-- ------- the Let's Go distribution: every healthy party member, in full
--
-- The engine's own rule is that only the Pokemon that FOUGHT are paid,
-- and they split one award between them; EXP.ALL exists to soften that.
-- Let's Go deletes the whole arrangement -- everybody gains from
-- everything, which is why that game ships no EXP.ALL at all -- and
-- each one is measured against its OWN level, so the low member of a
-- party pulls several times what the high one does from the same
-- knockout.
--
-- Two ways in. A CATCH arrives with a bonus stack attached (throw
-- grade, first ball, new species, combo) which `expCtx` carries. A
-- KNOCKOUT under FULL takes the same distribution with no stack --
-- those bonuses are rewards for the throw, and there was no throw.
--
-- Everything else -- CATCH ONLY, the row switched off, another mod's
-- battle -- falls through to the engine's own split untouched.
-- ------- and it is announced ONCE, not once per Pokemon
--
-- Six party members would otherwise mean six "X gained N EXP. Points!"
-- boxes per knockout. The per-Pokemon lines are suppressed (the `false`
-- to applyShare) and one card is shown instead -- see lib/ExpPanel.lua
-- for why that is better than a faster wall of the same text.
--
-- The card is queued BEFORE the loop that fills it. That is not a race:
-- applyShare applies its experience immediately and only QUEUES its
-- messages, so the loop runs to completion synchronously here, while
-- the queue does not reach the card's factory until later -- by which
-- time `rows` is complete. Queueing it first is what puts the tally
-- ahead of the "grew to level" chatter it is a summary of.
local ExpPanel = V.require("ExpPanel")
local function payParty(ctx, mult)
local battle = ctx.battle
local rows = {}
battle:uiNext(function() return ExpPanel.new(battle.game, rows) end)
granting = { mult = mult }
local okAward, err = pcall(function()
for _, mon in ipairs(battle.game.save.party) do
if mon.hp > 0 then
local exp0, lv0 = mon.exp, mon.level
ctx.applyShare(mon, 1, false)
rows[#rows + 1] = { mon = mon, gained = mon.exp - exp0,
from = lv0, to = mon.level }
end
end
end)
granting = nil
if not okAward then error(err, 0) end
end
mod.hooks:wrap("battle.exp_award", function(next_, ctx)
local cc = expCtx
if cc and ctx and ctx.battle == cc.battle then
expCtx = nil
return payParty(ctx, cc.mult)
end
if LetsGo.mode() == "full" and ctx and ctx.battle then
return payParty(ctx, 1)
end
return next_(ctx)
end)
-- and the amount, per receiving mon, while that loop runs
mod.hooks:wrap("exp.gain", function(next_, c)
if not granting then return next_(c) end
return scaledGain(c, granting.mult)
end)
-- ------- FULL owns a wild encounter from its first frame
--
-- The engine's intro ends by sending the player's Pokemon out -- the
-- back pic slides off, "Go! X!", the poof, the grow-in -- and a Let's
-- Go wild has no player Pokemon in it at all. The send-out is exactly
-- the LAST SIX rows of the intro queue when this event fires (built in
-- BattleState's start, gated `not safari and not demo`), so they are
-- stripped by SHAPE -- act, wait, act, say, POOF, act -- and left alone
-- if a future engine moves them: the veil still hides the visuals, the
-- engine just narrates a send-out that is not shown.
--
-- Stripping them leaves showPlayerBack TRUE for the whole battle, which
-- is the flag the engine's own HUD path reads as "no player HUD" -- the
-- player's side vanishes from the readout for free.
--
-- The veil goes up in the same breath: the capture table, installed
-- before any session exists, so the whole encounter -- wipe, "Wild X
-- appeared!", every beat between throws -- plays from the held head-on
-- seat with the player's side out of the shot.
mod.events:on("battle.started", function(payload)
local b = payload and payload.battle
if not (b and LetsGo.fullWild(b)) then return end
if not (Voxel3D.available() and not vrOn()) then return end
if not CatchThrow.pickBall() then return end
local q = b.queue
local n = q and #q or 0
if n >= 6 and type(q[n]) == "table" and q[n].fn
and q[n - 1] and q[n - 1].anim == "POOF_ANIM"
and q[n - 2] and q[n - 2].text
and q[n - 3] and q[n - 3].fn
and q[n - 4] and q[n - 4].wait
and q[n - 5] and q[n - 5].fn then
for _ = 1, 6 do table.remove(q) end
end
pcall(CatchThrow.veil, b)
end)
-- a battle ending sweeps everything: the capture epilogue, the veil, a
-- session a script tore down, and the exp context if the payout never
-- fired
mod.events:on("battle.ended", function()
expCtx = nil
pcall(CatchThrow.onBattleEnded)
end)
CatchThrow.installInput()
end
return LetsGo
+8
View File
@@ -64,6 +64,14 @@ function Mat4.rotateX(a)
0, 0, 0, 1 }
end
function Mat4.rotateZ(a)
local c, s = math.cos(a), math.sin(a)
return { c, -s, 0, 0,
s, c, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1 }
end
-- The rotation a unit quaternion describes, row-major. The VR rig is what
-- needs it: an OpenXR eye pose arrives as position + orientation
-- quaternion, and both the eye's transform and its inverse (the view) are
+88 -27
View File
@@ -355,6 +355,10 @@ end
function OverworldBattle.textRects(battle)
if not battle or battle.blankForAskName then return {} end
-- a capture session aiming has no text to put in the box and takes it
-- off the frame (see the drawTextArea wrap): no box, no glass under it
local cap = BattleScene.capture
if cap and cap.hideTextBox then return {} end
local r = OverworldBattle.TEXT_RECT
local out = { box = r.box }
if battle.phase == "moveSelect" then
@@ -615,7 +619,13 @@ function OverworldBattle.update(dt)
-- was solved for (the slow drift aside, which was always there). Polled
-- per frame rather than latched at battle start: the row is reachable
-- from the mod manager's page mid-session.
-- A LET'S GO capture session holds it too: the throw is aimed in this
-- exact framing, and a camera that moved under a ball in flight would
-- bend where the flick was pointed after the fact. (The session also
-- sets BattleCam.still, which is what stops the drift -- see
-- CatchThrow.begin.)
BattleCam.steerable = not OverworldBattle.backPinned()
and not BattleScene.capture
-- the right stick, read as a rate before the rig is built from it: the
-- wheel, the keys, the mouse and a drag all arrive as events and have
-- already landed, but a stick is a HELD position and only a tick can
@@ -698,15 +708,11 @@ function OverworldBattle.update(dt)
-- reason the scene is: it binds a canvas of its own. After the frost, so
-- the glass is frosted from the world alone and never from the glyphs
-- about to sit on it.
local ios = isIOS()
local okHud, up = false, false
if not ios then
okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
end
local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
session.snapped = (okHud and up) and true or false
-- once per battle, not once per frame: a driver that cannot do this cannot
-- do it sixty times a second either, and the fallback is silent and fine
if not ios and not okHud and not session.hudWarned then
if not okHud and not session.hudWarned then
session.hudWarned = true
V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
.. "in the battle frame this battle", tostring(up))
@@ -751,6 +757,30 @@ function OverworldBattle.battle()
return session.battle
end
-- The staged fight's arena and floor height, for the capture mode: the
-- foe's world cell is the far end of the throw and the floor is what a
-- short ball bounces on. nil whenever there is nothing staged, which is
-- one of the gates that sends a throw back to the engine's own toss.
function OverworldBattle.arenaInfo()
if not (session and session.arena and not session.broken) then return nil end
local host = session.arena.map or (session.state and session.state.map)
if not host then return nil end
return session.arena, BattleScene.groundY(host, session.arena)
end
-- The foe's rendered pic texture, for the capture mode's ring. The mark
-- BattleScene pins is the CELL's ground point, but a species' art sits
-- wherever the artist drew it in the frame -- a bird hovers half a slot
-- above its own feet row -- and a timing ring belongs on the CREATURE,
-- not on its patch of grass. The capture session reads this canvas back
-- once and centres the ring on the art's opaque box. nil on the STADIUM
-- rungs (the foe is a model, no pic is rendered) and before the first
-- textures pass, both of which the caller treats as "use the heuristic".
function OverworldBattle.enemyTexture()
if not (session and session.textures) then return nil end
return session.textures.enemy
end
-- The move-animation layer as a texture: the engine's own drawAnimLayer,
-- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized
-- transparent canvas of its own. This is what stands the effects up in
@@ -1107,7 +1137,12 @@ function OverworldBattle.textures(battle)
local out = {}
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
local okP, player = true, nil
if not OverworldBattle.backPinned() then
-- a LET'S GO capture session empties the player's side the same way
-- BACK SPRITES does: that mon is simply not in this shot, so no pic is
-- rendered for it and no shadow lands under it
local cap = BattleScene.capture
if not OverworldBattle.backPinned()
and not (cap and cap.hidePlayer) then
okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
end
out.enemy = okE and enemy or nil
@@ -1239,6 +1274,11 @@ function OverworldBattle.install()
self.letterboxWhite = false
OverworldBattle.drawHudPanels(self)
withoutBackgroundFill(self, innerDraw)
-- the LET'S GO capture overlay -- the timing ring, the ball readout,
-- the grade splash -- drawn last in the same GB frame the engine's
-- own HUD drew in, so it letterboxes and chunks identically
local cap = BattleScene.capture
if cap and cap.drawGB then pcall(cap.drawGB, self) end
end
-- The mons are geometry standing on the map now, drawn in the 3D pass
@@ -1256,6 +1296,10 @@ function OverworldBattle.install()
if not shot then
return innerPics(self, slide, sx, sy, onlySide, skipMenuClip)
end
-- a capture session shows NO player side at all -- not even the
-- pinned back pic BACK SPRITES would keep on the menu
local cap = BattleScene.capture
if cap and cap.hidePlayer then return end
if OverworldBattle.backPinned() and onlySide ~= "enemy" then
-- under the hour's own light, like everything else in the frame -- see
-- withTint, and the tint BattleScene hands over with the shot.
@@ -1278,7 +1322,13 @@ function OverworldBattle.install()
local innerText = BattleState.drawTextArea
function BattleState:drawTextArea()
if not self.dramaticShapeShot then return innerText(self) end
if isIOS() then return innerText(self) end
-- While a capture session is being AIMED the box is empty -- the
-- battle's phase is parked, so there is no message in it -- and it
-- covers the bottom third of the frame, which is exactly the room a
-- throw needs to wind up in. So it comes off entirely for those
-- frames and is back the instant a message has something to say.
local cap = BattleScene.capture
if cap and cap.hideTextBox then return end
return withoutBoxFill(self, innerText)
end
@@ -1451,13 +1501,18 @@ function OverworldBattle.snapHUDs(battle, shot)
local rects, bandX = OverworldBattle.snapRects(shot)
local enemy, player = OverworldBattle.hudLive(battle, slide)
local live = {}
if enemy then live.enemy = rects.enemy end
if player then live.player = rects.player end
-- and the text box's own glass, on the same pass. It stays in the middle of
-- the frame where the engine draws it -- only the HUDs were snapped out --
-- so its GB rect is mapped into the letterbox rather than to an edge.
for key, rect in pairs(OverworldBattle.textRects(battle)) do
live[key] = toWorld(rect, shot)
if not isIOS() then
if enemy then live.enemy = rects.enemy end
if player then live.player = rects.player end
end
-- The text box's frost panel normally goes into this same world-canvas pass.
-- On iOS that panel is mirrored upward by the Canvas-to-Canvas path, creating
-- the large ghost rectangle behind the Pokemon. Keep the box border/text but
-- skip only this frosted backing on iOS.
if not isIOS() then
for key, rect in pairs(OverworldBattle.textRects(battle)) do
live[key] = toWorld(rect, shot)
end
end
local layer = OverworldBattle.hudTexture(battle, slide)
if not layer then return false end
@@ -1473,8 +1528,24 @@ function OverworldBattle.snapHUDs(battle, shot)
for side, band in pairs(OverworldBattle.HUD_BAND) do
local quad = g.newQuad(band[1], band[2], band[3], band[4],
BattleScene.GB_W, BattleScene.GB_H)
g.draw(layer, quad, bandX[side] + band[1] * shot.scale,
shot.ly + band[2] * shot.scale, 0, shot.scale, shot.scale)
local x = bandX[side] + band[1] * shot.scale
local targetY = shot.ly + band[2] * shot.scale
if isIOS() then
-- Keep the player's HUD exactly where it currently appears on the
-- right. Only the enemy band needs its mirrored destination corrected.
local y = targetY
if side == "enemy" then
y = shot.ph - targetY - band[4] * shot.scale
end
-- iOS presents this Canvas-to-Canvas HUD texture upside down.
g.draw(layer, quad, x, y, 0,
shot.scale, -shot.scale, 0, band[4])
else
g.draw(layer, quad, x, targetY, 0,
shot.scale, shot.scale)
end
end
end)
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
@@ -1494,16 +1565,6 @@ end
function OverworldBattle.drawHudPanels(battle)
local shot = battle.dramaticShapeShot
if not shot then return end
if isIOS() then
local slide = (battle.introSlide or 0) * 4
local enemy, player = OverworldBattle.hudLive(battle, slide)
local rect = OverworldBattle.HUD_RECT
love.graphics.setColor(1, 1, 1, 0.84)
if enemy then love.graphics.rectangle("fill", rect.enemy[1], rect.enemy[2], rect.enemy[3], rect.enemy[4]) end
if player then love.graphics.rectangle("fill", rect.player[1], rect.player[2], rect.player[3], rect.player[4]) end
love.graphics.setColor(1, 1, 1, 1)
return
end
if snapped() then
return
end
+594
View File
@@ -0,0 +1,594 @@
-- A Poke Ball as real geometry: the prop the LET'S GO capture mode throws.
--
-- The mod has never drawn a ball in 3D -- the one the engine tosses is a 2D
-- sprite inside the battle's move-animation layer. This is a ball that can
-- fly through the arena, hang in the air in front of the camera, hinge its
-- lid open, drink a Pokemon in, click shut, rock on the ground and burst
-- back open -- all of it depth-tested, sun-shadowed and hour-tinted like
-- everything else in the diorama, because it is a mesh in Voxel3D's own
-- format going through Voxel3D's own shader.
--
-- ------- how it is built
--
-- Two lat/long hemisphere shells that meet at the equator -- the WHITE base
-- and the coloured LID -- each carrying its half of the black band as a
-- slightly bulged latitude belt, so the two halves separate exactly where
-- the real ball separates. The button is a little cylinder standing out of
-- the base's front; the interior is sealed with two pale discs so an open
-- ball shows a shell with a floor rather than a view through to the far
-- wall's backface. Colour is a palette texture one texel per material and
-- one ROW per ball tier (POKE/GREAT/ULTRA/MASTER/SAFARI), exactly the
-- HordeGun/Pokedex scheme -- so GREAT is blue and ULTRA wears its yellow
-- band without a second mesh, just a different V coordinate.
--
-- Shade is baked per vertex from the surface normal with StadiumStage's
-- fitted constants, which is this mod's answer for anything curved: the
-- ball's sun side and belly read as a sphere under the same southeastern
-- sun the roofs are lit by.
--
-- ------- how it animates
--
-- The HordeGun way: a handful of scalar timers advanced by update(dt) and
-- consumed as matrix terms at draw time. No skeleton, no keyframes --
-- lid is a hinge matrix about the back of the equator, the wobble is a
-- decaying rotateZ about the ground contact point, the caught click is a
-- squash pulse, the stars are one shared quad drawn a few times facing the
-- eye. The ball owns its POSE only; where it IS (the throw arc, the drop)
-- is the caller's problem, which is what keeps this file a prop and not a
-- game mode.
--
-- Nothing here touches love.* until something has to be drawn, so the
-- module loads and the state machine runs headless -- the test suite
-- exercises the phases without a GPU.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local Voxel3D = V.require("Voxel3D")
local Pokeball = {}
Pokeball.__index = Pokeball
-- ------- the ball's measurements, in world pixels
--
-- A map cell is 16 and a full-size mon card is 16 wide, so a 4.4-pixel ball
-- sits in the hand and against a Pokemon at about the proportion the games
-- draw: unmissable in the foreground, believable at the far cell.
Pokeball.R = 2.2
-- the black belt: half-height as a latitude angle, and how far the belt
-- bulges past the shell so it reads as a band and not a painted stripe
local BAND_LAT = 0.16
local BAND_R = 1.045
-- lid hinge: at the BACK of the equator (-Z), opening backward. 2.0 rad is
-- past upright -- the mouth gapes at the sky, which is the capture pose.
local HINGE_Z = -0.86 -- as a fraction of R
local LID_OPEN = 2.0
-- tessellation: enough that the silhouette is round at held-ball size,
-- cheap enough that six of these would not show on a phone's frame budget
local LON = 14
local LAT = 5
-- pose timing
local LID_RATE = 6.5 -- lid open/close, in lid-fractions per second
local BURST_RATE = 14 -- the breakout pop is a violent open
local WOBBLE_T = 0.85 -- one rock, seconds
local WOBBLE_A = 0.38 -- how far it tips, radians
local PULSE_T = 0.14 -- the caught click's squash pulse
local STAR_T = 0.9 -- the caught stars' life
local GLOW_DECAY = 2.2 -- additive glow, per second
-- ------- palette
--
-- One texel per material (columns), one row per ball tier. Alpha stays 1
-- everywhere: the voxel shader discards below 0.5 (Voxel3D's SHADER), so a
-- translucent texel is an invisible one.
local SLOTS = { TOP = 1, BOTTOM = 2, BAND = 3, RING = 4, FACE = 5,
INNER = 6, GLOW = 7, STAR = 8 }
local SLOT_N = 8
local TIERS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL",
"SAFARI_BALL" }
local COLORS = {
POKE_BALL = { top = { 0.86, 0.16, 0.16 }, band = { 0.12, 0.12, 0.13 },
bottom = { 0.93, 0.93, 0.95 } },
GREAT_BALL = { top = { 0.25, 0.45, 0.88 }, band = { 0.12, 0.12, 0.13 },
bottom = { 0.93, 0.93, 0.95 } },
ULTRA_BALL = { top = { 0.22, 0.22, 0.26 }, band = { 0.85, 0.70, 0.18 },
bottom = { 0.93, 0.93, 0.95 } },
MASTER_BALL = { top = { 0.48, 0.22, 0.66 }, band = { 0.16, 0.13, 0.19 },
bottom = { 0.93, 0.93, 0.95 },
glow = { 1.0, 0.72, 0.92 } },
SAFARI_BALL = { top = { 0.47, 0.52, 0.26 }, band = { 0.36, 0.27, 0.16 },
bottom = { 0.90, 0.88, 0.80 } },
}
local SHARED = {
ring = { 0.28, 0.28, 0.30 },
face = { 0.96, 0.96, 0.97 },
inner = { 0.72, 0.70, 0.68 },
glow = { 1.00, 0.92, 0.65 },
star = { 1.00, 0.85, 0.25 },
}
local function tierRow(ball)
for i, id in ipairs(TIERS) do
if id == ball then return i end
end
return 1 -- an unknown ball is a plain POKE BALL
end
-- palette texel centres
local function uvFor(slot, row)
return (slot - 0.5) / SLOT_N, (row - 0.5) / #TIERS
end
local palette = nil
local function paletteTexture()
if palette ~= nil then return palette or nil end
local ok, img = pcall(function()
local data = love.image.newImageData(SLOT_N, #TIERS)
for row, id in ipairs(TIERS) do
local c = COLORS[id]
local function put(slot, rgb)
data:setPixel(slot - 1, row - 1, rgb[1], rgb[2], rgb[3], 1)
end
put(SLOTS.TOP, c.top)
put(SLOTS.BOTTOM, c.bottom)
put(SLOTS.BAND, c.band)
put(SLOTS.RING, SHARED.ring)
put(SLOTS.FACE, SHARED.face)
put(SLOTS.INNER, SHARED.inner)
put(SLOTS.GLOW, c.glow or SHARED.glow)
put(SLOTS.STAR, SHARED.star)
end
local tex = love.graphics.newImage(data)
tex:setFilter("nearest", "nearest")
return tex
end)
palette = ok and img or false
return palette or nil
end
-- ------- shade
--
-- StadiumStage's fitted form of Voxel3D.FACE_SHADE: the same southeastern
-- sun, answered for an arbitrary normal instead of one of six faces.
local function shadeFor(nx, ny, nz)
local s = 0.7725 + nx * 0.06 + ny * 0.225 + nz * 0.11
return math.max(0.30, math.min(1.00, s))
end
-- ------- mesh building
--
-- Everything below appends {x,y,z, u,v, shade} rows plus triangle indices.
-- Quads go through the shared corner order; the discs use a degenerate
-- fourth vertex, which the rasteriser drops as the zero-area triangle it is.
local function quad(verts, map, a, b, c, d)
local n = #verts
verts[n + 1], verts[n + 2], verts[n + 3], verts[n + 4] = a, b, c, d
Voxel3D.pushQuad(map, n / 4)
end
local R = Pokeball.R
local TAU = math.pi * 2
-- a latitude zone of the sphere between phi0 and phi1 (radians from the
-- equator, north positive), at radiusK times the shell radius
local function zone(verts, map, phi0, phi1, rows, slot, row, radiusK)
local u, v = uvFor(slot, row)
local r = R * (radiusK or 1)
for i = 0, rows - 1 do
local pa = phi0 + (phi1 - phi0) * (i / rows)
local pb = phi0 + (phi1 - phi0) * ((i + 1) / rows)
for j = 0, LON - 1 do
local ta = TAU * (j / LON)
local tb = TAU * ((j + 1) / LON)
local function corner(phi, th)
local nx = math.cos(phi) * math.sin(th)
local ny = math.sin(phi)
local nz = math.cos(phi) * math.cos(th)
return { nx * r, ny * r, nz * r, u, v, shadeFor(nx, ny, nz) }
end
quad(verts, map, corner(pa, ta), corner(pa, tb),
corner(pb, tb), corner(pb, ta))
end
end
end
-- a disc in a y-plane, sealed with fan quads about the centre
local function disc(verts, map, y, radius, slot, row, up)
local u, v = uvFor(slot, row)
local sh = shadeFor(0, up and 1 or -1, 0)
local centre = { 0, y, 0, u, v, sh }
for j = 0, LON - 1 do
local ta = TAU * (j / LON)
local tb = TAU * ((j + 1) / LON)
local a = { radius * math.sin(ta), y, radius * math.cos(ta), u, v, sh }
local b = { radius * math.sin(tb), y, radius * math.cos(tb), u, v, sh }
quad(verts, map, centre, a, b, centre)
end
end
-- the button: a ring wall and its face, standing out of the shell along +Z
local function button(verts, map, row)
local BLON = 10
local function ringWall(rad, z0, z1, slot)
local u, v = uvFor(slot, row)
for j = 0, BLON - 1 do
local ta = TAU * (j / BLON)
local tb = TAU * ((j + 1) / BLON)
local function at(th, z)
local nx, ny = math.cos(th), math.sin(th)
return { rad * nx, rad * ny, z, u, v, shadeFor(nx, ny, 0) }
end
quad(verts, map, at(ta, z0), at(tb, z0), at(tb, z1), at(ta, z1))
end
end
local function faceDisc(rad, z, slot)
local u, v = uvFor(slot, row)
local sh = shadeFor(0, 0, 1)
local centre = { 0, 0, z, u, v, sh }
for j = 0, BLON - 1 do
local ta = TAU * (j / BLON)
local tb = TAU * ((j + 1) / BLON)
local a = { rad * math.cos(ta), rad * math.sin(ta), z, u, v, sh }
local b = { rad * math.cos(tb), rad * math.sin(tb), z, u, v, sh }
quad(verts, map, centre, a, b, centre)
end
end
-- the wall starts inside the shell so the junction never shows a gap
ringWall(0.75, R * 0.90, R + 0.30, SLOTS.RING)
faceDisc(0.75, R + 0.30, SLOTS.RING)
ringWall(0.45, R + 0.30, R + 0.42, SLOTS.RING)
faceDisc(0.45, R + 0.42, SLOTS.FACE)
end
-- one tier's meshes, memoised: { base = , lid = , spark = }
--
-- spark is a shared unit card (x -0.5..0.5, y 0..1, z 0) wearing one texel;
-- the glow disc, the beam and every star are that card under a matrix.
local meshes = {}
local function meshesFor(ball)
local row = tierRow(ball)
local hit = meshes[row]
if hit ~= nil then return hit or nil end
local ok, built = pcall(function()
local bv, bm = {}, {}
-- the base: white bowl from the south pole up to the band, its half of
-- the band, the interior floor and the button on the front
zone(bv, bm, -math.pi / 2, -BAND_LAT, LAT, SLOTS.BOTTOM, row)
zone(bv, bm, -BAND_LAT, 0, 1, SLOTS.BAND, row, BAND_R)
disc(bv, bm, -0.06, R * 0.97, SLOTS.INNER, row, true)
button(bv, bm, row)
local lv, lm = {}, {}
-- the lid: its half of the band up to the coloured dome, and its pale
-- underside, which is what shows once the hinge tips it back
zone(lv, lm, 0, BAND_LAT, 1, SLOTS.BAND, row, BAND_R)
zone(lv, lm, BAND_LAT, math.pi / 2, LAT, SLOTS.TOP, row)
disc(lv, lm, 0.06, R * 0.97, SLOTS.INNER, row, false)
local base = Voxel3D.newMesh(bv, bm)
local lid = Voxel3D.newMesh(lv, lm)
if not (base and lid) then return nil end
local function card(slot)
local u, v = uvFor(slot, row)
local cv, cm = {}, {}
quad(cv, cm, { -0.5, 0, 0, u, v, 1 }, { 0.5, 0, 0, u, v, 1 },
{ 0.5, 1, 0, u, v, 1 }, { -0.5, 1, 0, u, v, 1 })
return Voxel3D.newMesh(cv, cm)
end
return { base = base, lid = lid,
glow = card(SLOTS.GLOW), star = card(SLOTS.STAR) }
end)
meshes[row] = (ok and built) or false
return meshes[row] or nil
end
-- dropped so a lost GL context (Android resume) rebuilds everything
function Pokeball.invalidate()
meshes = {}
palette = nil
end
-- ------- an instance: one ball with a pose
--
-- Loads and runs without graphics; only draw() and cast() want a GPU.
function Pokeball.new(ball)
return setmetatable({
ball = ball or "POKE_BALL",
pos = { 0, 0, 0 }, -- world pixels, the ball's CENTRE
yaw = 0, -- which way the button faces
scale = 1,
spin = 0, -- visual spin about the vertical, rad/s
tumble = 0, -- end-over-end in flight, rad/s
roll = 0, -- SCREEN-PLANE spin, rad/s: rotation about
-- the axis out of the ball's face, which
-- with the yaw at the camera reads as the
-- ball turning clockwise/counter-clockwise
-- to the viewer -- the curveball wind-up
spinAngle = 0, tumbleAngle = 0, rollAngle = 0,
lid = 0, lidTarget = 0, lidRate = LID_RATE,
wobbleT = nil, wobbleDir = 1,
pulse = nil, -- the caught click's squash
glow = 0,
stars = nil, -- caught celebration, or nil
visible = true,
}, Pokeball)
end
-- ------- the verbs the capture flow speaks
function Pokeball:open()
self.lidTarget, self.lidRate = 1, LID_RATE
self.glow = 1
end
function Pokeball:close()
self.lidTarget, self.lidRate = 0, LID_RATE
end
-- one rock on the ground; dir alternates shakes. Returns how long it takes,
-- so the caller can sequence the pauses between shakes.
function Pokeball:rock(dir)
self.wobbleT = 0
self.wobbleDir = dir or 1
return WOBBLE_T
end
-- the caught click: squash pulse, a soft flash, and the stars
function Pokeball:catchClick()
self.pulse = 0
self.glow = 0.6
local stars = {}
for i = 1, 6 do
stars[i] = { t = -0.04 * (i - 1), th = TAU * (i - 1) / 6 + 0.4 }
end
self.stars = stars
end
-- the breakout: the lid blown open and a hard flash
function Pokeball:burst()
self.lidTarget, self.lidRate = 1, BURST_RATE
self.glow = 1
end
function Pokeball:busy()
return self.wobbleT ~= nil or self.pulse ~= nil
or math.abs(self.lid - self.lidTarget) > 0.02
end
function Pokeball:update(dt)
-- lid toward its target, at whatever violence was asked for
local d = self.lidTarget - self.lid
if d ~= 0 then
local step = self.lidRate * dt
if math.abs(d) <= step then
-- arriving CLOSED from open is the shut click: the squash pulse
if self.lid > self.lidTarget then self.pulse = self.pulse or 0 end
self.lid = self.lidTarget
else
self.lid = self.lid + (d > 0 and step or -step)
end
end
if self.wobbleT then
self.wobbleT = self.wobbleT + dt
if self.wobbleT >= WOBBLE_T then self.wobbleT = nil end
end
if self.pulse then
self.pulse = self.pulse + dt
if self.pulse >= PULSE_T then self.pulse = nil end
end
if self.stars then
local live = false
for _, s in ipairs(self.stars) do
s.t = s.t + dt
if s.t < STAR_T then live = true end
end
if not live then self.stars = nil end
end
self.glow = math.max(0, self.glow - GLOW_DECAY * dt)
self.spinAngle = self.spinAngle + self.spin * dt
self.tumbleAngle = self.tumbleAngle + self.tumble * dt
self.rollAngle = self.rollAngle + self.roll * dt
end
-- ------- pose as a matrix
local function smooth(t)
if t <= 0 then return 0 end
if t >= 1 then return 1 end
return t * t * (3 - 2 * t)
end
function Pokeball:matrix()
local m = Mat4.mul(Mat4.translate(self.pos[1], self.pos[2], self.pos[3]),
Mat4.rotateY(self.yaw))
if self.wobbleT then
-- a decaying rock about the ground contact: tip, cross through centre,
-- tip the other way, settle
local t = self.wobbleT / WOBBLE_T
local a = WOBBLE_A * math.sin(TAU * t) * (1 - t) * self.wobbleDir
m = Mat4.mul(m, Mat4.mul(Mat4.translate(0, -R * self.scale, 0),
Mat4.mul(Mat4.rotateZ(a),
Mat4.translate(0, R * self.scale, 0))))
end
if self.spinAngle ~= 0 then m = Mat4.mul(m, Mat4.rotateY(self.spinAngle)) end
if self.tumbleAngle ~= 0 then
m = Mat4.mul(m, Mat4.rotateX(self.tumbleAngle))
end
-- the roll turns about the ball's own face axis, so with the yaw aimed
-- at the camera it reads as clockwise/counter-clockwise on screen
if self.rollAngle ~= 0 then
m = Mat4.mul(m, Mat4.rotateZ(self.rollAngle))
end
local k = self.scale
if self.pulse then
-- the click: a quick squash and back, more felt than seen
local p = math.sin((self.pulse / PULSE_T) * math.pi) * 0.14
m = Mat4.mul(m, Mat4.scale(k * (1 + p), k * (1 - p), k * (1 + p)))
elseif k ~= 1 then
m = Mat4.mul(m, Mat4.scale(k, k, k))
end
return m
end
-- the hinge: the lid's own extra transform about the back of the equator
local function lidMatrix(open)
if open <= 0 then return nil end
local a = -LID_OPEN * smooth(open)
local hz = HINGE_Z * R
return Mat4.mul(Mat4.translate(0, 0, hz),
Mat4.mul(Mat4.rotateX(a), Mat4.translate(0, 0, -hz)))
end
-- where the open mouth is, for aiming the capture beam
function Pokeball:mouth()
return self.pos[1], self.pos[2] + R * 0.4 * self.scale, self.pos[3]
end
-- ------- drawing
--
-- Assumes a live Voxel3D scene (between beginScene and endScene), exactly
-- like Stadium.draw. Seams and glass are off for the duration: the ball is
-- not on the voxel grid and does not wear the tileset atlas.
local function eyeYaw(x, z)
local eye = Voxel3D.eye
if not eye then return 0 end
return math.atan2(eye[1] - x, eye[3] - z)
end
function Pokeball:draw(pull)
if not self.visible then return end
local m = meshesFor(self.ball)
local pal = paletteTexture()
if not (m and pal) then return end
Voxel3D.seams(false)
Voxel3D.glass(false)
local model = self:matrix()
Voxel3D.draw(m.base, pal, model, pull)
local lidM = lidMatrix(self.lid)
Voxel3D.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model, pull)
-- the additive dressing: the open-mouth glow and the caught stars.
-- Depth writes are off under "add" (Voxel3D.blend), so these can never
-- punch holes for later draws.
local anythingAdd = (self.glow > 0.05 and self.lid > 0.1) or self.stars
if anythingAdd then
Voxel3D.blend("add")
if self.glow > 0.05 and self.lid > 0.1 then
-- a pulsing octahedron of light standing in the mouth: two crossed
-- cards read from every seat in the house
local gx, gy, gz = self:mouth()
local s = R * (1.1 + 0.25 * self.glow) * self.scale
for i = 0, 1 do
local card = Mat4.mul(Mat4.translate(gx, gy, gz),
Mat4.mul(Mat4.rotateY(eyeYaw(gx, gz) + i * math.pi / 2),
Mat4.scale(s, s, 1)))
Voxel3D.draw(m.glow, pal, card, pull)
end
end
if self.stars then
for _, s in ipairs(self.stars) do
if s.t > 0 and s.t < STAR_T then
local t = s.t / STAR_T
local rr = (R + 4.5 * t) * self.scale
local sx = self.pos[1] + math.sin(s.th) * rr
local sz = self.pos[3] + math.cos(s.th) * rr
local sy = self.pos[2] + (R + 7 * t - 5 * t * t) * self.scale
local sc = 1.1 * (1 - t)
local card = Mat4.mul(Mat4.translate(sx, sy, sz),
Mat4.mul(Mat4.rotateY(eyeYaw(sx, sz)),
Mat4.mul(Mat4.rotateZ(TAU * t * 0.5),
Mat4.scale(sc, sc, 1))))
Voxel3D.draw(m.star, pal, card, pull)
end
end
end
Voxel3D.blend(nil)
end
Voxel3D.glass(true)
Voxel3D.seams(true)
end
-- the capture beam: a crossed pair of additive cards stretched from the
-- ball's mouth to the mon it is drinking in. Separate from draw() because
-- the caller owns the far end and the fade.
function Pokeball:drawBeam(tx, ty, tz, width, strength, pull)
if not self.visible then return end
local m = meshesFor(self.ball)
local pal = paletteTexture()
if not (m and pal) then return end
local x, y, z = self:mouth()
local dx, dy, dz = tx - x, ty - y, tz - z
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
if len < 0.5 then return end
dx, dy, dz = dx / len, dy / len, dz / len
-- two perpendiculars to the beam axis
local ux, uy, uz
if math.abs(dy) < 0.94 then
ux, uy, uz = -dz, 0, dx -- cross(d, worldUp), unnormalised
local l = math.sqrt(ux * ux + uz * uz)
ux, uz = ux / l, uz / l
else
ux, uy, uz = 1, 0, 0
end
local vx = dy * uz - dz * uy
local vy = dz * ux - dx * uz
local vz = dx * uy - dy * ux
local w = (width or R) * (strength or 1)
Voxel3D.seams(false)
Voxel3D.glass(false)
Voxel3D.blend("add")
-- the unit card is x -0.5..0.5, y 0..1: columns map its x to a
-- perpendicular and its y to the full run of the axis
local a = { ux * w, dx * len, vx, x,
uy * w, dy * len, vy, y,
uz * w, dz * len, vz, z,
0, 0, 0, 1 }
local b = { vx * w, dx * len, ux, x,
vy * w, dy * len, uy, y,
vz * w, dz * len, uz, z,
0, 0, 0, 1 }
Voxel3D.draw(m.glow, pal, a, pull)
Voxel3D.draw(m.glow, pal, b, pull)
Voxel3D.blend(nil)
Voxel3D.glass(true)
Voxel3D.seams(true)
end
-- ------- the sun's view
--
-- The same two shells under the same matrix, so the shadow on the ground is
-- the pose the camera sees. The caller folds a term into the shadow
-- signature while a ball is live (the sun pass is cached -- see
-- BattleScene.shadowSignature) or this freezes on its first frame.
function Pokeball:cast(shadowMap)
if not self.visible then return end
local m = meshesFor(self.ball)
local pal = paletteTexture()
if not (m and pal) then return end
local model = self:matrix()
shadowMap.draw(m.base, pal, model)
local lidM = lidMatrix(self.lid)
shadowMap.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model)
end
-- a term for the arena's cached shadow signature: quantised, so the cache
-- only re-renders when the ball has visibly moved
function Pokeball:signature()
if not self.visible then return "" end
return table.concat({ math.floor(self.pos[1] * 4), math.floor(self.pos[2] * 4),
math.floor(self.pos[3] * 4), math.floor(self.lid * 8),
self.wobbleT and math.floor(self.wobbleT * 30) or -1 },
",")
end
return Pokeball
+45
View File
@@ -391,6 +391,13 @@ function Stadium.update(dt, battle, groundY)
if mon.species then StadiumPack.keep(mon.species) end
mon.visible = (mon.rig ~= nil) and onField(battle, side, mon)
and not (battler and battler.substituteHP)
-- LET'S GO capture mode: the player's model is out of the shot the
-- same way its card and back pic are (the shrink half of the story is
-- below, AFTER the grow block, which reassigns mon.scale every frame)
local cap = V.require("BattleScene").capture
if side == "player" and cap and cap.hidePlayer then
mon.visible = false
end
-- cleared up front, so a side that has just lost its rig cannot leave
-- last frame's matrix behind it
mon.model_matrix = nil
@@ -425,6 +432,16 @@ function Stadium.update(dt, battle, groundY)
local okG, grow = pcall(battle.growInScale, battle, battler)
mon.scale = (okG and grow) or 1
end
-- LET'S GO capture: the foe drinking into the ball. AFTER the grow
-- block on purpose -- that block reassigns mon.scale every frame,
-- and the first cut of this hook sat above it and was silently
-- clobbered: the model stood at full size over a ball that had
-- supposedly swallowed it. The session's fraction owns the scale
-- for as long as it exists; the frame it clears, the grow block
-- above is already putting the engine's own answer back.
if side == "enemy" and cap and cap.shrink then
mon.scale = cap.shrink
end
mon:update(dt or 0)
if mon.visible and arena then
local cell = arena[side]
@@ -491,6 +508,34 @@ function Stadium.guard(side, mon, what, fn)
return false
end
-- The foe's body for the capture mode's collision and ring, when a MODEL
-- is standing there instead of a pic: its own measured height and
-- footprint, in world pixels. A model stands on the ground, so the body's
-- centre is half its height up. nil whenever no model covers the foe,
-- which sends CatchThrow to its pic measurement instead.
function Stadium.captureBody()
if not session then return nil end
local mon = session.enemy
if not (mon and mon.rig and mon.visible) then return nil end
local okH, h = pcall(mon.worldHeight, mon)
if not (okH and h and h > 0) then return nil end
-- The POSED body, when there is one: a flying Pokemon is nowhere near
-- the mark its cell projects to, and only the pose knows where it went
-- (StadiumMon:bodySpan). The bind-pose figures stand in until the
-- first skin, which is right for everything that keeps its feet down.
local okS, centre, half, girth = pcall(mon.bodySpan, mon)
if okS and centre then
return { r = math.max(5, math.min(16, math.max(girth or 0, half * 0.8))),
yOff = centre,
hh = math.max(4, half) }
end
local okR, r = pcall(mon.worldRadius, mon)
local rr = (okR and r and r > 0) and r or h * 0.4
return { r = math.max(5, math.min(16, math.max(rr, h * 0.5))),
yOff = h * 0.5,
hh = math.max(4, h * 0.55) }
end
function Stadium.draw(pull)
if not session then return end
for _, side in ipairs({ "enemy", "player" }) do
+49
View File
@@ -456,6 +456,55 @@ function StadiumMon:matrix(x, groundY, z, faceX, faceZ)
Mat4.translate(0, -lift, 0))
end
-- How far this Pokemon's LOWEST rendered point stands above the ground it
-- is placed on, in world pixels -- the authored hover the matrix above
-- gives back, actually applied.
--
-- Derived by repeating that matrix's own arithmetic rather than by
-- re-deriving it in closed form: root scale, the model's floor and the
-- hover cap interact in a way that is easy to get subtly wrong, and a
-- caller that guessed would place things at the feet of a Pokemon that is
-- flying. Which is exactly what a Pidgey does -- it renders a good third
-- of its own height clear of its tile, and anything aimed at its cell
-- mark lands under it.
function StadiumMon:groundGap()
local centre, half = self:bodySpan()
if centre then return math.max(0, centre - half) end
return 0
end
-- Where this Pokemon's body actually SITS above the ground it is placed
-- on, and how big it is: the centre height, the half height and the
-- girth, all in world pixels.
--
-- Measured off the POSED vertices (StadiumRig:posedBounds) and put
-- through this matrix's own scale and lift, so the answer is the shape
-- the camera is about to see. That matters most for the species it is
-- hardest to guess about: a Pidgey's standby animation flies it well
-- clear of its tile, and anything aimed at its cell mark -- a capture
-- ring, a thrown ball's collision -- lands under an empty patch of grass
-- while the bird hovers above it. Nothing static says so; only the pose
-- does.
--
-- nil before the first skin(), or with no rig: the caller falls back to
-- the bind-pose height, which is right for everything that stands.
function StadiumMon:bodySpan()
local model, rig = self.model, self.rig
if not (model and rig and rig.posedBounds) then return nil end
local okB, lo, hi, girth = pcall(rig.posedBounds, rig)
if not (okB and lo) then return nil end
local root = model.rootScale
if not (root and root > 0) then root = 1 end
local k = root * self:worldHeight() / math.max(model.height, 1e-6)
k = k * (self.scale or 1)
local floor = model.floor or 0
local hover = math.min(math.max(floor, 0),
StadiumMon.HOVER_CAP * math.max(model.height, 0))
local lift = (floor - hover) / root
-- the same map the model matrix applies: world = k * (posed - lift)
return k * ((lo + hi) * 0.5 - lift), k * (hi - lo) * 0.5, k * (girth or 0)
end
-- Pose and skin for this frame. Separate from the draw because both the
-- SUN and the camera -- and, in a headset, both eyes -- want the same
-- skinned mesh, and skinning it once is the whole reason this is worth
+30
View File
@@ -728,6 +728,36 @@ function StadiumRig:skin(yaw)
end
end
-- What this POSE actually occupies, in the rig's own posed space: the
-- vertical span of every skinned vertex, and the furthest any of them
-- stands from the model's vertical axis.
--
-- Read off the skinned rows rather than off the pack's bind-pose figures,
-- because the two are not the same claim. The bind measurements say how
-- big the model is; a caller placing something ON the Pokemon needs to
-- know where the Pokemon IS, and for a flying species the standby
-- animation carries it a third of its own height off the floor -- a lift
-- that exists only in the posed bones and appears in no static field.
--
-- Answers nil before the first skin(), which is the caller's cue to fall
-- back to the bind figures.
function StadiumRig:posedBounds()
local lo, hi, r2 = nil, nil, 0
for _, part in ipairs(self.parts) do
local rows, n = part.rows, part.prim.vertCount
for k = 1, n do
local row = rows[k]
local y = row[2]
if not lo or y < lo then lo = y end
if not hi or y > hi then hi = y end
local d = row[1] * row[1] + row[3] * row[3]
if d > r2 then r2 = d end
end
end
if not lo then return nil end
return lo, hi, math.sqrt(r2)
end
-- ------- which texture each part wears this frame
--
-- The eyes. A primitive whose display list carried geo command 0x23 with a
+44
View File
@@ -104,6 +104,11 @@ local Horde = V.require("Horde")
local HordeGun = V.require("HordeGun")
local HordeHud = V.require("HordeHud")
local HordeSfx = V.require("HordeSfx")
-- LET'S GO: the flick-to-throw capture mode. LetsGo owns the row, the
-- wraps and the experience math; CatchThrow the session (input, arc,
-- ring, choreography); Pokeball the animated prop they throw.
local LetsGo = V.require("LetsGo")
local Pokeball = V.require("Pokeball")
-- Forward declaration: the voxel pipeline's update hook (registered below)
-- calls this, and it is defined further down with the settings it drives.
@@ -196,6 +201,19 @@ mod.content.render_pipelines:register("voxel", {
-- the atmosphere's own clock (shaft shimmer, drifting motes), on the
-- same tick so the beams keep breathing through a dialog box
ForestAtmos.update(dt)
-- LET'S GO rides the same always-running tick, and BEFORE the battle's
-- own update on purpose: the capture session poses the Poke Ball here,
-- and OverworldBattle.update renders the arena a moment later -- so
-- the ball each frame draws is the ball that frame computed. Guarded,
-- and loudly: a fault in the capture game must cost the capture game,
-- not the whole voxel pipeline.
do
local okLG, errLG = pcall(LetsGo.update, dt)
if not okLG and not V.letsGoWarned then
V.letsGoWarned = true
mod.log:warn("LET'S GO update failed: %s", tostring(errLG))
end
end
-- The overworld battle rides this hook rather than owning a pipeline of
-- its own, because it owns no pass of the FRAME: it draws under a battle
-- screen the engine composites, which is not a stage the registry has.
@@ -309,6 +327,7 @@ mod.content.render_pipelines:register("voxel", {
ChunkMesher.invalidate() -- no map id = every cached mesh
ForestAtmos.invalidate() -- shaft/particle meshes and shader sentinels
VR.invalidate() -- the mirror, and FBO ids of dead canvases
Pokeball.invalidate() -- the ball's meshes and palette texture
end,
})
@@ -499,6 +518,22 @@ local SETTINGS = {
.. "The foe is still out there on its own tile.",
when = function() return stagedBattles() and not VR.enabled() end,
full = true },
-- `full` like the battle rows: this is a GAMEPLAY mode, not a knob on
-- the diorama, so the FULL preset neither sets it nor takes it away.
{ LetsGo.setting,
"Pokemon GO-style catching, staged in the 3D battle. Flick the mouse, "
.. "a finger or the right stick to throw the ball at the wild Pokemon "
.. "-- spin it first for a curve -- and land inside the shrinking "
.. "ring for a NICE, GREAT or EXCELLENT that raises the catch odds. "
.. "CATCH ONLY changes nothing else: picking a ball in battle simply "
.. "plays the throw. FULL is the whole Let's Go treatment: wild "
.. "encounters open straight in throwing mode (B backs out to the "
.. "classic menu), Poke/Great/Ultra Balls are half price, and a catch "
.. "pays the whole party experience -- scaled by throw quality, first "
.. "throws, new species and your running catch combo. Needs 3D-BTL "
.. "on; anywhere the staged fight cannot stand, balls quietly throw "
.. "the classic way.",
full = true },
{ DayNight.setting,
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
@@ -1136,6 +1171,15 @@ end
-- become the same eight buttons. See lib/Horde.lua.
Horde.install()
-- ------- LET'S GO capture mode
--
-- After every other input seam on purpose: while a throw is being aimed
-- the capture's mouse and touch wraps are the OUTERMOST, so the flick is
-- read before anything else can claim the pointer -- and outside the aim
-- they forward every byte untouched. The battle-side wraps (throwBall,
-- safariAction) and the experience hooks install here too.
LetsGo.install()
-- ------- edge-anchored menus stay in the GB frame while a headset is live
--
-- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.7.1",
"version": "1.7.2",
"api": 2,
"entry": "main.lua",
"profile": "content",
+99 -7
View File
@@ -406,14 +406,14 @@ local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
-- (nothing to store, nothing for the mod manager to persist) and is offered
-- on every platform, saying WHERE? rather than IMPORT where there is no file
-- dialog to open
T.eq(#hookedRows, 12, "the options hook added a row per setting, plus the "
T.eq(#hookedRows, 13, "the options hook added a row per setting, plus the "
.. "STADIUM ROM action row")
local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5]
local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[9]
-- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6] and AA
-- hookedRows[10]; all three are read where they are used rather than named
-- here, because this chunk is one main function and has 200 local slots to
-- spend
local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[10]
-- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6], LET'S GO
-- hookedRows[9] and AA hookedRows[11]; all four are read where they are
-- used rather than named here, because this chunk is one main function and
-- has 200 local slots to spend
T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label")
T.eq(hookedRows[4].value(), "FIT",
"and defaults to FIT -- the cut IS the window the flat game already "
@@ -446,6 +446,9 @@ T.eq(backRow.value(), "OFF",
.. "map, so the classic slot is opt-in")
T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true),
"on its own key, so it persists beside 3D-BTL rather than over it")
T.eq(hookedRows[9].label, "LET'S GO", "the capture-mode row carries its label")
T.eq(hookedRows[9].value(), "OFF",
"and starts OFF -- a gameplay mode is opt-in, whatever the diorama does")
-- stepping writes through to the one place both rows read
local settingGame = { save = { options = {} }, mods = { modOptions = {} } }
@@ -524,7 +527,7 @@ do
local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
local aaGame = { save = { options = {} }, mods = { modOptions = {} } }
local aa = hookedRows[10]
local aa = hookedRows[11]
T.eq(aa.label, "AA", "the anti-aliasing row carries its label")
T.eq(aa.value(), "OFF",
"and starts off -- supersampling is a cost knob, and a mod must not spend "
@@ -6275,6 +6278,95 @@ VB.setting:sync(boxWas)
VS.angle = angleWas
end)()
-- ------- LET'S GO: the capture mode's arithmetic and the ball's pose
--
-- The throw itself is a rendered, pointer-driven thing the suite cannot
-- fly, but everything consequential under it is plain arithmetic and runs
-- headless: the scaled experience formula (checked against the published
-- Let's Go table), the combo ladder, and the Pokeball prop's state
-- machine, which must load and animate without a GPU.
;(function()
local lib = run.loader.exports.DRAMATIC_SHAPE.lib
local LetsGo = lib.require("LetsGo")
local Pokeball = lib.require("Pokeball")
local Mat4 = lib.require("Mat4")
-- the reference points from the community-measured table: a Lv30
-- Chansey (base exp 395) pays 2371 to a Lv30 receiver and 5497 to a
-- Lv10 one, before any catch bonuses
T.eq(LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, level = 30,
mon = { level = 30 } }, 1), 2371,
"the scaled formula at equal levels is b*L/5 + 1 -- the table's 2371")
T.eq(LetsGo._scaledGain({ defeatedDef = { baseExp = 395 }, level = 30,
mon = { level = 10 } }, 1), 5497,
"and a Lv10 receiver pulls the table's 5497 from the same catch -- "
.. "the receiver's own level is in the denominator")
T.check(LetsGo._scaledGain({ defeatedDef = { baseExp = 40 }, level = 3,
mon = { level = 100 } }, 1) >= 1,
"a trivial catch still pays at least one point")
-- and the same formula pays TRAINER knockouts under FULL, with the
-- wild/trainer 1.5 that a catch never sees (a caught Pokemon is always
-- wild, which is why the catch numbers above are untouched by it)
local wildKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 },
level = 30, mon = { level = 30 } }, 1)
local trainerKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 },
level = 30, mon = { level = 30 },
isTrainer = true }, 1)
T.eq(wildKO, 2371, "a wild payout is unchanged by the trainer term")
T.eq(trainerKO, 3556, "a trainer's Pokemon pays the same formula at 1.5")
T.check(trainerKO > wildKO, "which is more than the same wild one")
-- the receiver's own level still drives it, which is the whole point of
-- sharing to the party: the low member gains multiples of the high one
local lowKO = LetsGo._scaledGain({ defeatedDef = { baseExp = 395 },
level = 30, mon = { level = 5 },
isTrainer = true }, 1)
T.check(lowKO > trainerKO * 2,
"a level 5 party member takes several times what a level 30 one does "
.. "from the very same knockout")
-- the combo ladder's five tiers
T.eq(LetsGo._comboMult(1), 1.1, "a fresh combo is the 1.1 tier")
T.eq(LetsGo._comboMult(10), 1.1, "which runs to ten")
T.eq(LetsGo._comboMult(11), 1.5, "eleven starts the 1.5 tier")
T.eq(LetsGo._comboMult(21), 2.0, "twenty-one the 2.0")
T.eq(LetsGo._comboMult(31), 2.5, "thirty-one the 2.5")
T.eq(LetsGo._comboMult(41), 3.0, "and forty-one caps it at 3.0")
T.eq(LetsGo._comboMult(999), 3.0, "where it stays")
-- the row answers the mode, and OFF answers false
T.eq(LetsGo.mode(), false, "LET'S GO defaults to OFF")
-- the ball: a full open-drop-rock-click life without a GPU
T.eq(Mat4.rotateZ ~= nil, true, "Mat4 grew the roll the wobble rocks on")
do
local m = Mat4.rotateZ(math.pi / 2)
-- row-major: x' of (1,0,0) is m[1], y' is m[5]
T.check(math.abs(m[1]) < 1e-9 and math.abs(m[5] - 1) < 1e-9,
"rotateZ carries +X onto +Y, the right-handed roll")
end
local ball = Pokeball.new("GREAT_BALL")
T.eq(ball.lid, 0, "a fresh ball is shut")
ball:open()
for _ = 1, 60 do ball:update(1 / 60) end
T.eq(ball.lid, 1, "open() hinges the lid fully over its ramp")
T.check(ball.glow < 1, "and the mouth glow decays on its own")
ball:close()
for _ = 1, 60 do ball:update(1 / 60) end
T.eq(ball.lid, 0, "close() brings it back")
local dur = ball:rock(1)
T.check(dur > 0, "a rock reports its duration for the caller's cadence")
T.check(ball:busy(), "and the ball is busy while it rocks")
for _ = 1, math.ceil(dur * 60) + 2 do ball:update(1 / 60) end
T.check(not ball:busy(), "then settles")
ball:catchClick()
T.check(ball.stars ~= nil, "the caught click spawns its stars")
local matrix = ball:matrix()
T.eq(#matrix, 16, "the pose is one model matrix")
for _ = 1, 120 do ball:update(1 / 60) end
T.check(ball.stars == nil, "and the stars burn out on their own")
end)()
Pipelines.reset()
run.release()
+309
View File
@@ -0,0 +1,309 @@
-- Driver: photograph the LET'S GO capture mode end to end.
--
-- One wild encounter under FULL: the battle menu should never be seen --
-- capture mode opens over it with the ball hanging at the player's empty
-- cell and the ring pulsing on the foe -- then a synthetic mouse flick
-- throws the ball and the beats that follow are photographed: flight,
-- the open-mouth suck, the drop-and-wobble, and the outcome.
--
-- SHOT_DIR=.scratchpad/letsgo \
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_shots.lua \
-- "/c/Program Files/LOVE/lovec.exe" .
--
-- SHOT_DIR must already exist. DS_LETSGO_MODE=catching runs the same
-- beats through the bag-menu route instead of the FULL auto-entry.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
local MODE = os.getenv("DS_LETSGO_MODE") or "full"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Bag = require("src.inventory.Bag")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local LetsGo = lib.require("LetsGo")
local CatchThrow = lib.require("CatchThrow")
local BattleScene = lib.require("BattleScene")
-- a spread of levels, because the Let's Go award pays every party
-- member against its OWN level: a low one should pull far more from
-- the same catch than a high one, and a flat payout would hide that
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 45),
Pokemon.new(game.data, "PIKACHU", 10),
Pokemon.new(game.data, "RATTATA", 5),
}
game.save.player.name = "RED"
Bag.add(game.save, "POKE_BALL", 20, game.data)
Bag.add(game.save, "GREAT_BALL", 5, game.data)
-- DS_BALL forces the ball, so a MASTER_BALL run is a guaranteed catch
-- and the experience payout can be read deterministically
local FORCE = os.getenv("DS_BALL")
if FORCE then
Bag.add(game.save, FORCE, 5, game.data)
CatchThrow.lastBall = FORCE
end
local expBefore = {}
for i, m in ipairs(game.save.party) do
expBefore[i] = { exp = m.exp, level = m.level }
end
LetsGo.setting:setValue(MODE, game)
-- DS_RUNG=cards forces the 2D-3D A rung: the control run for the pic
-- measurement path, against the STADIUM models the save may be on
if os.getenv("DS_RUNG") == "cards" then
lib.require("OverworldBattle").setting:setValue(true, game)
U.log("3D-BTL forced to 2D-3D A")
end
U.log("LET'S GO mode: " .. tostring(LetsGo.mode()))
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(90)
local battle = BattleState.newWild(game, "PIDGEY", 5)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
-- the wipe and the "Wild X appeared!" chatter: tap until the menu (or
-- capture mode, which under FULL opens the instant the menu would)
U.wait(70)
for i = 1, 80 do
if battle.phase == "menu" or CatchThrow.session() then break end
U.tap(game, "a")
U.wait(6)
if i % 20 == 0 then
U.log(("still driving intro: phase=%s queue=%d anim=%s")
:format(tostring(battle.phase), #(battle.queue or {}),
tostring(battle.animPlaying)))
end
end
local function phase()
local s = CatchThrow.session()
return s and s.phase or ("battle:" .. tostring(battle.phase))
end
if MODE == "catching" then
-- the bag route: ITEM on the menu, then the ball
U.wait(20)
U.log("menu phase: " .. tostring(battle.phase))
-- cursor to ITEM (menu is 2x2: fight pkmn / item run) -- down once
U.tap(game, "down"); U.wait(4)
U.tap(game, "a"); U.wait(20)
-- the bag opens on the first item; balls were added first
U.tap(game, "a"); U.wait(10)
else
-- FULL: capture mode should have opened on its own
U.wait(30)
end
U.log("capture phase: " .. phase())
do
local s = CatchThrow.session()
if s then
local b = s.artBox
U.log(("probe: span=%.1f enemyGB=%.0f,%.0f playerGB=%.0f,%.0f")
:format(s.shot.enemySpan or -1, s.shot.enemy[1], s.shot.enemy[2],
s.shot.player[1], s.shot.player[2]))
U.log(("probe: eye=%.0f,%.0f,%.0f enemyW=%.0f,%.0f playerW=%.0f,%.0f")
:format(s.shot.eye[1], s.shot.eye[2], s.shot.eye[3],
s.enemyPos[1], s.enemyPos[3],
s.playerPos[1], s.playerPos[3]))
if b then
U.log(("probe: artBox ax=%d ay=%d box=%d..%d,%d..%d body r=%.1f yOff=%.1f")
:format(b.ax, b.ay, b.x0, b.x1, b.y0, b.y1,
s.body.r, s.body.yOff))
else
U.log(("probe: artBox=nil body r=%.1f yOff=%.1f")
:format(s.body.r, s.body.yOff))
end
end
end
U.shot(game, DIR .. "/1_aim.png")
-- ------- the reach test
--
-- Drag the ball to the far corners of the WINDOW -- including well
-- below where the battle's text box used to be -- and report where it
-- actually ends up in the GB frame. A fence shows up here as a ball
-- that stops moving while the pointer keeps going.
do
local W, H = love.graphics.getDimensions()
local s = CatchThrow.session()
if s and love.mousepressed then
local hx, hy = s.handGB[1], s.handGB[2]
love.mousepressed(W * 0.5, H * 0.62, 1, false, 1)
U.wait(2)
local CORNERS = {
{ "bottom-centre", 0.50, 0.97 },
{ "bottom-left", 0.06, 0.97 },
{ "bottom-right", 0.94, 0.97 },
{ "top-left", 0.06, 0.10 },
}
for _, c in ipairs(CORNERS) do
for i = 1, 4 do
if love.mousemoved then
love.mousemoved(W * c[2], H * c[3], 0, 0, false)
end
U.wait(1)
end
local q = CatchThrow.session()
U.log(("reach %-14s -> ball GB %.0f,%.0f (frame is 160x144)")
:format(c[1], q and q.handGB[1] or -1, q and q.handGB[2] or -1))
if c[1] == "bottom-centre" then
U.shot(game, DIR .. "/1b_drag_low.png")
end
end
-- a slow release is a dead flick: nothing is thrown
if love.mousereleased then
love.mousereleased(W * 0.06, H * 0.10, 1, false, 1)
end
U.wait(30)
U.log("after reach test: " .. phase()
.. (" (ball home was %.0f,%.0f)"):format(hx, hy))
end
end
-- A synthetic flick aimed like a hand: from the session's own geometry,
-- pick a release point short of the ring and a sweep speed for a
-- comfortable sigma, so the aim model (release + reach along the flick)
-- lands the ball on the ring centre. Window units; the session maps
-- them back to GB through the live letterbox.
local aim = CatchThrow._aimInfo()
if aim then
local uw, uh = love.graphics.getDimensions()
local function toWin(gx, gy)
return (aim.lx + gx * aim.scale) * uw / aim.pw,
(aim.ly + gy * aim.scale) * uh / aim.ph
end
-- the throw is the swipe's own velocity now: grab the ball and sweep
-- toward the ring at a comfortable ~190 GB px/s for 8 frames.
-- DS_SWIPE overrides it, which is how the strength band is swept:
-- weak should fall short, comfortable should land, hard should still
-- reach rather than sail over.
local SPEED = tonumber(os.getenv("DS_SWIPE") or "") or 190
local FRAMES = 8
SPEED_USED = SPEED
local hx, hy = aim.hand[1], aim.hand[2]
local dx, dy = aim.ring[1] - hx, aim.ring[2] - hy
local d = math.sqrt(dx * dx + dy * dy)
dx, dy = dx / d, dy / d
U.log(("aim: hand %.0f,%.0f ring %.0f,%.0f outer %.0f")
:format(hx, hy, aim.ring[1], aim.ring[2], aim.outer))
local step = SPEED / 60
if love.mousepressed then
local px, py = toWin(hx, hy)
love.mousepressed(px, py, 1, false, 1)
end
for i = 1, FRAMES do
local wx, wy = toWin(hx + dx * step * i, hy + dy * step * i)
if love.mousemoved then love.mousemoved(wx, wy, 0, 0, false) end
U.wait(1)
end
if love.mousereleased then
local wx, wy = toWin(hx + dx * step * FRAMES, hy + dy * step * FRAMES)
love.mousereleased(wx, wy, 1, false, 1)
end
-- what actually left the hand, before gravity has touched it
local s0 = CatchThrow.session()
if s0 and s0.vel then
local p = s0.ballInst.pos
U.log(("LAUNCH fwd/up/lat = %.1f %.1f %.1f from height %.1f, %.1f px out")
:format(math.sqrt(s0.vel[1] ^ 2 + s0.vel[3] ^ 2), s0.vel[2], 0,
p[2] - s0.groundY,
math.sqrt((s0.enemyPos[1] - p[1]) ^ 2
+ (s0.enemyPos[3] - p[3]) ^ 2)))
else
U.log("LAUNCH -- nothing was thrown (flick rejected)")
end
else
U.log("NO AIM INFO -- capture mode did not open")
end
U.wait(4)
-- did it CONNECT? poll the flight out and say plainly which way it
-- went, so a strength sweep reads as a table instead of a guess
do
local verdict, peak = "no throw", 0
for _ = 1, 200 do
local s = CatchThrow.session()
if not s then verdict = "session gone" break end
if s.phase == "flight" then
local h = s.ballInst.pos[2] - s.groundY
if h > peak then peak = h end
verdict = "MISS (fell short / wide)"
elseif s.phase == "suck" or s.phase == "drop"
or s.phase == "wobble" then
verdict = "HIT" .. (s.tier and (" " .. s.tier) or " (outside ring)")
break
elseif s.phase ~= "aim" then
break
end
U.wait(1)
end
local body = CatchThrow.session() and CatchThrow.session().body
U.log(("THROW swipe=%d -> %s (apex %.1f world px, body centre %.1f)")
:format(SPEED_USED or -1, verdict, peak, body and body.yOff or -1))
end
U.log("after flick: " .. phase())
U.shot(game, DIR .. "/2_flight.png")
-- the suck: ball open at the foe, beam on, mon shrinking
for _ = 1, 60 do
U.wait(1)
local s = CatchThrow.session()
if s and s.phase == "suck" then break end
if not CatchThrow.session() then break end
end
U.log("suck check: " .. phase()
.. " shrink=" .. tostring(BattleScene.capture
and BattleScene.capture.shrink))
U.shot(game, DIR .. "/3_suck.png")
-- the drop and the first wobble
for _ = 1, 120 do
U.wait(1)
local s = CatchThrow.session()
if s and s.phase == "wobble" then break end
if not s then break end
end
U.wait(30)
U.log("wobble check: " .. phase()
.. " shrink=" .. tostring(BattleScene.capture
and BattleScene.capture.shrink))
U.shot(game, DIR .. "/4_wobble.png")
-- the outcome: stars or burst, then the engine's own text
for _ = 1, 300 do
U.wait(1)
local s = CatchThrow.session()
if not s or s.phase == "epilogue" or s.phase == "burst" then break end
end
U.wait(20)
U.log("outcome: " .. phase())
U.shot(game, DIR .. "/5_outcome.png")
-- under FULL a breakout must land straight back in throw mode -- no
-- enemy turn between; a catch plays out its epilogue instead
-- keep tapping through the caught chatter so storeCaughtMon (and the
-- experience payout hanging off it) actually runs
for _ = 1, 60 do U.tap(game, "a"); U.wait(6) end
U.log(("after outcome: %s battle=%s balls=%d")
:format(phase(), tostring(battle.phase),
game.save.inventory.POKE_BALL or 0))
for i, m in ipairs(game.save.party) do
local was = expBefore[i]
local nm = game.data.pokemon[m.species].name
U.log(("EXP %-10s Lv%-3d -> Lv%-3d exp %d -> %d (+%d)")
:format(nm, was.level, m.level, was.exp, m.exp, m.exp - was.exp))
end
local combo = lib.require("LetsGo").combo()
U.log("combo: " .. (combo and (combo.species .. " x" .. combo.count)
or "none"))
U.shot(game, DIR .. "/6_after.png")
U.log("done -- " .. DIR)
end
+163
View File
@@ -0,0 +1,163 @@
-- Driver: does a TRAINER knockout pay the whole party under LET'S GO FULL?
--
-- The catch payout is easy to see (one throw, one award). A knockout is
-- not: it goes through the engine's own faint -> awardExp path, which is
-- the one this mode replaces wholesale. So fight a real trainer with a
-- deliberately uneven party and read the deltas -- every member should
-- gain, and the low ones should gain multiples of the high one.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_trainer_exp.lua \
-- SHOT_DIR=.scratchpad/letsgo "/c/Program Files/LOVE/lovec.exe" .
--
-- DS_LETSGO_MODE=off runs the same fight on the engine's own rules, which
-- is the control: there, only the Pokemon that fought should gain.
return function(game)
local U = dofile("tests/drivers/util.lua")
local MODE = os.getenv("DS_LETSGO_MODE") or "full"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
if not lib then
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
return
end
local LetsGo = lib.require("LetsGo")
LetsGo.setting:setValue(MODE == "off" and false or MODE, game)
U.log("LET'S GO mode: " .. tostring(LetsGo.mode()))
-- count the summary cards at their SOURCE rather than by catching them
-- on the stack: the driver taps fast enough to dismiss one between two
-- polls, and an absence there would look like a card that never came
local ExpPanel = lib.require("ExpPanel")
local innerNew, cards = ExpPanel.new, 0
ExpPanel.new = function(g, rows)
local panel = innerNew(g, rows)
cards = cards + 1
-- read at DRAW time in the real thing; here the loop that fills it
-- has already run, so peeking now is honest
local parts = {}
for _, r in ipairs(rows or {}) do
parts[#parts + 1] = ("%s+%d%s"):format(
(g.data.pokemon[r.mon.species] or {}).name or "?", r.gained or 0,
(r.to or 0) > (r.from or 0) and ("->L" .. r.to) or "")
end
U.log(("EXP CARD #%d: %s"):format(cards, table.concat(parts, " ")))
CARD_HOLD = 20 -- frames to stop tapping, so it can be shot
return panel
end
-- one strong fighter and two bystanders: only the fighter gains on the
-- engine's own rules, so the bystanders ARE the test
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 45),
Pokemon.new(game.data, "PIKACHU", 10),
Pokemon.new(game.data, "RATTATA", 5),
}
game.save.player.name = "RED"
-- The WEAKEST party in the game: one Pokemon, lowest level. Picked by
-- measuring rather than by name -- an alphabetical first pick lands on
-- OPP_AGATHA, whose Elite Four ghosts a level 45 Charizard does not
-- reliably clear, and a fight that never resolves reads exactly like a
-- payout that never happened.
local class, partyIx, best = nil, 1, nil
local ids = {}
for id in pairs(game.data.trainers) do
if type(id) == "string" and id:sub(1, 1) ~= "_" then ids[#ids + 1] = id end
end
table.sort(ids) -- stable across runs
for _, id in ipairs(ids) do
local rec = game.data.trainers[id]
for pi, party in ipairs((type(rec) == "table" and rec.parties) or {}) do
local n, top = 0, 0
for _, mon in ipairs(party) do
n = n + 1
top = math.max(top, tonumber(mon.level) or 0)
end
if n > 0 then
local score = n * 100 + top
if not best or score < best then
best, class, partyIx = score, id, pi
end
end
end
end
U.log(("fighting %s party %d (weakest of %d classes)")
:format(tostring(class), partyIx, #ids))
local before = {}
for i, m in ipairs(game.save.party) do
before[i] = { exp = m.exp, level = m.level }
end
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(60)
local battle = BattleState.newTrainer(game, class, partyIx)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
-- through the intro to the menu, then FIGHT + first move, over and
-- over until the fight resolves. The enemy's HP is logged as it goes,
-- so a fight that stalls is visibly a stall rather than a silent zero.
local lastHP, shotPanel = nil, false
for i = 1, 900 do
-- the fight ending does NOT end the loop until the card has been
-- photographed: the last knockout resolves the battle in the same
-- breath that queues the card, so breaking on `result` alone leaves
-- every run with the card built and never seen
if battle.result and shotPanel then break end
-- hold the taps while the card is up, so it can be shot rather than
-- dismissed on the next frame
if (CARD_HOLD or 0) > 0 then
CARD_HOLD = CARD_HOLD - 1
if not shotPanel then
local top = game.stack and game.stack:top()
if top and rawget(top, "rows") then
shotPanel = true
U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad")
.. "/exp_panel.png")
U.log("shot the card")
end
end
U.wait(1)
elseif battle.phase == "menu" then
battle.menuIndex = 1 -- FIGHT
U.tap(game, "a")
elseif battle.phase == "moveSelect" then
battle.moveIndex = 1
U.tap(game, "a")
else
U.tap(game, "a")
end
local hp = battle.enemy and battle.enemy.mon and battle.enemy.mon.hp
if hp ~= lastHP then
lastHP = hp
U.log((" foe HP -> %s (phase %s, step %d)")
:format(tostring(hp), tostring(battle.phase), i))
end
-- photograph the summary card the moment it is on top, then let the
-- taps carry on and dismiss it
local top = game.stack and game.stack:top()
if top and not shotPanel and top ~= battle and rawget(top, "rows") then
shotPanel = true
U.shot(game, (os.getenv("SHOT_DIR") or ".scratchpad")
.. "/exp_panel.png")
U.log("shot the card")
end
U.wait(5)
end
U.log("battle result: " .. tostring(battle.result)
.. " phase=" .. tostring(battle.phase))
for i, m in ipairs(game.save.party) do
local was = before[i]
U.log(("EXP %-10s Lv%-3d -> Lv%-3d exp %d -> %d (+%d)")
:format(game.data.pokemon[m.species].name, was.level, m.level,
was.exp, m.exp, m.exp - was.exp))
end
U.log("done")
end
+83
View File
@@ -0,0 +1,83 @@
-- Driver: the two sloped-roof drawings in Pallet Town, shot at the orbit
-- rungs, to see what the roof surface wears down a TAPERED flank.
--
-- A tapered column's first drawn row is its silhouette cap, and the cap is
-- outline black. The depth map spends most of the roof's depth above that
-- cap, so a surface clamped onto it paints the outline the length of the
-- slope and the course cycle beats against it -- black teeth marching down
-- the hip. The surface belongs on the column's first PAINTED row instead.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_cap_shots.lua \
-- SHOT_DIR=.scratchpad/roofcap AB_TAG=before lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcap")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[cap] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(30)
end
-- B07 (the gabled house: Red's and Blue's) and B31 (Oak's lab) are the
-- two tapered roof bands standing on one map -- 16 drawn rows and 32,
-- the two groups every other sloped building borrows its band table
-- from. Standing south of each puts the hip end across the frame.
local SCENES = {
{ map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "reds_house" },
{ map = "PALLET_TOWN", x = 13, y = 3, face = "up", label = "blues_house" },
{ map = "PALLET_TOWN", x = 12, y = 12, face = "up", label = "oaks_lab" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
U.teleport(game, s.map, s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(8)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[cap] capture missed: " .. path) end
end
end
print(("[cap] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+56 -16
View File
@@ -1393,6 +1393,22 @@ def profile(sp, t):
wall_h = ground - t["roof_rows"]
ytop = wall_h - 1 + t["slab"]
# The row a column's roof SURFACE may sink to. `top[x]` is the
# silhouette cap -- the black the drawing closes its shape with -- and
# the depth map spends most of a tapered column's depth above it, so a
# clamp onto `top[x]` paints that one outline pixel across the slope
# and the courses beat against it. The surface belongs on the first
# PAINTED row instead, which is the same thing the side faces already
# do when the drawing's own pixel is the outline.
surface_top = []
for x in range(W):
y = top[x]
while (y < t["roof_rows"] and inside(x, y)
and sp["col"][y][x] == BLACK):
y += 1
surface_top.append(y if y < t["roof_rows"] and inside(x, y)
else top[x])
# Recesses: the panes the art seals behind a black frame. Non-black
# pixels of the facade split into components across the black outline;
# a component small enough to be a window or a doorway sinks one voxel.
@@ -1434,7 +1450,8 @@ def profile(sp, t):
# Depth is the PLOT. For a whole-drawing building that is the grid
# itself; `depth` (in tile rows) names it when the grid runs past the
# plot onto ground the drawing merely stands its legs on.
return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess,
return dict(top=top, surface_top=surface_top,
wall_h=wall_h, ytop=ytop, recess=recess,
inside=inside,
# `depth` names the plot in TILE ROWS, which is the right
# grain for a building. `depth_px` names it in voxels, for
@@ -1929,6 +1946,29 @@ def build_desk_set(sp, pr, t):
return vox
def roof_surface_row(pr, t):
"""Which drawn row the roof surface wears at column x, depth z.
The drawing looks at the roof from the north, so its rows ARE depth: the
rims map one row per voxel and the middle cycles a run whose period is
the course rhythm. A tapered column's band starts lower down the
drawing, and the row it wears is lifted to stay inside it."""
z0, z1 = 0, pr["D"] - 1 + t["front_eave"]
back, front = t["roof_back"], t["roof_front"]
c0, c1 = t["roof_cycle"]
rows, floor = t["roof_rows"], pr["surface_top"]
def depth_row(z):
df, db = z - z0, z1 - z # from the north / the south edge
if df < back:
return df # north rim: the drawing's top rows
if db < front:
return rows - 1 - db # south rim: fascia and eave course
return c0 + (df - c0) % (c1 - c0 + 1)
return (lambda x, z: max(depth_row(z), floor[x])), z0, z1
def build(sp, pr, t):
"""The voxel model. Order is load-bearing: walls, ledge, recesses, then
the roof solid overwrites what it intersects and the walls are trimmed
@@ -2007,17 +2047,7 @@ def build(sp, pr, t):
vox.pop((sx, pr["ground"] - 1 - sy, D - 1), None)
# ---- roof: flat top over the plateau, stepped diagonal ends
z0, z1 = 0, D - 1 + t["front_eave"]
back, front = t["roof_back"], t["roof_front"]
c0, c1 = t["roof_cycle"]
def roof_sy(z):
df, db = z - z0, z1 - z # from the north / the south edge
if df < back:
return df # north rim: the drawing's top rows
if db < front:
return t["roof_rows"] - 1 - db # south rim: fascia and eave course
return c0 + (df - c0) % (c1 - c0 + 1)
surface_row, z0, z1 = roof_surface_row(pr, t)
shade_px = {}
for sy in range(H):
@@ -2029,10 +2059,7 @@ def build(sp, pr, t):
tt = T(x)
for z in range(z0, z1 + 1):
outer = x == x0d or x == x1d or z == z0 or z == z1
# the slope's texture is the drawing's own: clamping into the
# column's first drawn row keeps flank battens running down the
# slope instead of falling off the silhouette
sy = max(roof_sy(z), pr["top"][x])
sy = surface_row(x, z)
for y in range(tt - slab + 1, tt + 1):
if y == tt and not outer:
put(x, y, z, x, sy)
@@ -2306,6 +2333,19 @@ def verify_roof(vox, pr, t):
# drawing's own corner rounding
assert all(ytop - v <= 1 for v in prof), "the flat roof is not level"
# The surface is surface, not outline. `top[x]` is the column's own
# silhouette cap -- the black the drawing closes the shape with, the
# same black measure() already refuses to let stand as a wall's side
# face -- so a surface that samples it promotes a boundary decoration
# into texture. A tapered column's cap sits well down the band, where
# the depth map spends most of the roof's depth above it.
surface_row, sz0, sz1 = roof_surface_row(pr, t)
for x in roofed[1:-1]:
for z in range(sz0 + 1, sz1):
assert surface_row(x, z) != top[x], \
f"roof surface wears the silhouette cap at {x},{z} " \
f"(row {top[x]})"
# every wall column carries roof over it -- and a column the roof never
# reaches carries nothing at all, rather than being silently trimmed away
over = set(roofed)