mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 16:31:07 +02:00
595 lines
22 KiB
Lua
595 lines
22 KiB
Lua
-- 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
|