Files
DramaticShapeVoxelMod/lib/Pokedex.lua
T
2026-08-02 13:10:20 -04:00

224 lines
8.9 KiB
Lua

-- VR: the POKEDEX in the player's left hand -- a voxel model of the
-- series' own field guide, strapped to the tracked grip pose, whose
-- screen is a real texture the mod can put a picture on.
--
-- Why it exists: a staged VR battle needs the 2D battle screen SOMEWHERE
-- -- the text, the menus, the HP bars are the game -- but a flat panel
-- floating square in front of the fight hides the fight. A trainer in
-- the world already has the right prop for "a handheld device with a
-- screen": look down at the Pokedex in your hand to read the battle,
-- look up to watch it happen on the map.
--
-- THE MODEL is authored here in voxels, in METRES (VOX metres a voxel),
-- around its own centre, front face +Z -- a red slab with the lens, the
-- LEDs, the hinge and a d-pad, and a dark bezel the screen sits proud
-- of. It rides VRRig.propMatrix, the same XR-to-world mapping the eyes
-- use, so it sits exactly where the hand is and keeps its real size in
-- every mode: a hand-sized device over the diorama, the same hand-sized
-- device at life scale in first person and in battle.
--
-- THE SCREEN is a separate one-quad mesh drawn with its own texture --
-- whatever canvas the caller hands `Pokedex.screen` (the VR frame hands
-- it the front buffer during a battle, cropped by UV to the battle's own
-- letterbox). No texture leaves the screen dark: a device that is off.
--
-- Everything here is passive state plus a draw call; VR.lua decides when
-- the frame exists (hand tracked, session live) and VoxelScene's eye
-- pass draws it after the world, so it composites with real depth
-- against everything else.
-- 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 VRRig = V.require("VRRig")
local Pokedex = {}
-- one voxel, in metres: a centimetre-ish grid gives the classic chunky
-- read at a device you can read a battle off (the body below comes out
-- about 12 x 19 x 3 cm -- a quarter up from the first, believable size,
-- because the screen carries every menu and was squint-small in hand)
Pokedex.VOX = 0.011 * 1.25
-- Where the device sits relative to the GRIP pose, in metres, and how it
-- is tipped. A full quarter turn forward lays the slab exactly along the
-- controller's own body -- verified in the headset -- so holding the
-- controller IS holding the device: raise your fist and the screen faces
-- you. These two are the whole of the attachment.
Pokedex.OFFSET = { 0, 0.04, -0.02 }
Pokedex.TILT = -math.pi / 2 -- radians about X: 90 degrees forward,
-- flush with the controller
-- body proportions, in voxels
local W, H, D = 9, 14, 2
-- the palette the body's faces point their UVs at, one texel per colour
local COLORS = {
{ 200, 40, 48 }, -- 1 body red
{ 140, 24, 32 }, -- 2 hinge / shaded red
{ 64, 132, 244 }, -- 3 the lens blue
{ 208, 228, 255 }, -- 4 lens glint
{ 232, 60, 48 }, -- 5 LED red
{ 248, 216, 64 }, -- 6 LED yellow
{ 72, 200, 96 }, -- 7 LED green
{ 46, 46, 54 }, -- 8 bezel / d-pad dark
{ 24, 24, 30 }, -- 9 the dark screen (the "off" state's face)
}
local paletteTex = nil -- one texel per COLORS entry
local bodyMesh = nil
local screenMesh = nil
local screenKey = nil -- the UV rect screenMesh was built for
local function palette()
if paletteTex then return paletteTex end
if not (love.image and love.image.newImageData
and love.graphics and love.graphics.newImage) then return nil end
local ok, data = pcall(love.image.newImageData, #COLORS, 1)
if not (ok and data) then return nil end
for i, c in ipairs(COLORS) do
pcall(data.setPixel, data, i - 1, 0,
c[1] / 255, c[2] / 255, c[3] / 255, 1)
end
local built, img = pcall(love.graphics.newImage, data)
if not built then return nil end
pcall(img.setFilter, img, "nearest", "nearest")
paletteTex = img
return img
end
-- Append one solid box's six faces to `verts`/`indices`: position in
-- voxels (relative to the device centre), size in voxels, colour by
-- palette index. Faces carry the mod's own directional shade, so the
-- slab reads as a solid the way every extruded block here does.
local function box(verts, indices, x, y, z, w, h, d, color)
local u = (color - 0.5) / #COLORS
local vox = Pokedex.VOX
local ox, oy, oz = (x - W / 2) * vox, (y - H / 2) * vox, (z - D / 2) * vox
local sx, sy, sz = w * vox, h * vox, d * vox
for face = 1, 6 do
local corners = Voxel3D.FACE_CORNERS[face]
local shade = Voxel3D.FACE_SHADE[face]
local n = #verts / 4
for _, c in ipairs(corners) do
verts[#verts + 1] = { ox + c[1] * sx, oy + c[2] * sy, oz + c[3] * sz,
u, 0.5, shade }
end
Voxel3D.pushQuad(indices, n)
end
end
-- the screen's face on the front, in voxels (10:9, the GB frame's shape),
-- shared by the dark "off" face in the body and the live quad
local SCREEN = { x = 1.2, y = 4.6, w = 6.6, h = 5.94 }
local function buildBody()
if bodyMesh then return bodyMesh end
local verts, indices = {}, {}
-- the slab, the hinge along the right edge, the lens, the LEDs, the
-- d-pad and two chunky buttons -- the classic cover furniture, one box
-- each on the front face (z = D..)
box(verts, indices, 0, 0, 0, W, H, D, 1) -- body
box(verts, indices, W - 0.7, 0, 0, 0.7, H, D + 0.15, 2) -- hinge
box(verts, indices, 0.6, H - 2.6, D, 2, 2, 0.5, 3) -- lens
box(verts, indices, 0.9, H - 1.3, D + 0.5, 0.6, 0.5, 0.12, 4) -- glint
box(verts, indices, 3.2, H - 1.6, D, 0.8, 0.8, 0.35, 5) -- LEDs
box(verts, indices, 4.5, H - 1.6, D, 0.8, 0.8, 0.35, 6)
box(verts, indices, 5.8, H - 1.6, D, 0.8, 0.8, 0.35, 7)
-- the bezel plate the screen sits in, and the dark screen face itself
-- (what shows when nothing is on: a device that is off, not a hole)
box(verts, indices, SCREEN.x - 0.4, SCREEN.y - 0.4, D,
SCREEN.w + 0.8, SCREEN.h + 0.8, 0.4, 8)
box(verts, indices, SCREEN.x, SCREEN.y, D + 0.4,
SCREEN.w, SCREEN.h, 0.1, 9)
-- d-pad below the screen, two crossed bars, and the A/B buttons
box(verts, indices, 5.6, 1.1, D, 2.1, 0.7, 0.45, 8)
box(verts, indices, 6.3, 0.4, D, 0.7, 2.1, 0.45, 8)
box(verts, indices, 1.2, 0.8, D, 1.1, 1.1, 0.45, 5)
box(verts, indices, 2.9, 0.8, D, 1.1, 1.1, 0.45, 8)
bodyMesh = Voxel3D.newMesh(verts, indices)
return bodyMesh
end
-- The live screen: one quad a hair proud of the dark face, UV-mapped to
-- `uv` = { u0, v0, u1, v1 } of whatever texture is on it. Rebuilt only
-- when the UV rect moves (a window resize moving the battle letterbox).
local function buildScreen(uv)
local key = table.concat({ uv[1], uv[2], uv[3], uv[4] }, ":")
if screenMesh and screenKey == key then return screenMesh end
local vox = Pokedex.VOX
local x0 = (SCREEN.x - W / 2) * vox
local y0 = (SCREEN.y - H / 2) * vox
local x1 = x0 + SCREEN.w * vox
local y1 = y0 + SCREEN.h * vox
local z = (D / 2 + 0.55) * vox
local u0, v0, u1, v1 = uv[1], uv[2], uv[3], uv[4]
local verts = {
{ x0, y0, z, u0, v1, 1 }, { x1, y0, z, u1, v1, 1 },
{ x1, y1, z, u1, v0, 1 }, { x0, y1, z, u0, v0, 1 },
}
local indices = {}
Voxel3D.pushQuad(indices, 0)
local mesh = Voxel3D.newMesh(verts, indices)
if mesh then
screenMesh, screenKey = mesh, key
end
return mesh
end
-- ------- the frame's state, set by VR.lua
--
-- nil = no pokedex this frame (no session, no tracked left hand).
Pokedex.frame = nil
-- Stand the device on a tracked LEFT-HAND pose under the current
-- XR-to-world mapping (the same pivot/anchor/scale/yaw the eyes got).
function Pokedex.place(pose, pivot, anchor, scale, yaw)
local m = VRRig.propMatrix(pose, pivot, anchor, scale, yaw)
m = Mat4.mul(m, Mat4.translate(Pokedex.OFFSET[1], Pokedex.OFFSET[2],
Pokedex.OFFSET[3]))
m = Mat4.mul(m, Mat4.rotateX(Pokedex.TILT))
Pokedex.frame = { model = m }
end
-- What the screen shows: a texture and the UV rect of it to fill the
-- screen with. nil for a dark screen. Only meaningful after place().
function Pokedex.screen(tex, u0, v0, u1, v1)
if Pokedex.frame and tex then
Pokedex.frame.tex = tex
Pokedex.frame.uv = { u0 or 0, v0 or 0, u1 or 1, v1 or 1 }
end
end
function Pokedex.clear()
Pokedex.frame = nil
end
-- Draw the device with the scene's own pass (model matrix in world px).
-- Runs inside VoxelScene's drawScene, per eye; no shadow-caster half --
-- a UI prop should receive the world's light, not throw shade on it.
function Pokedex.draw()
local f = Pokedex.frame
if not f then return end
local body = buildBody()
local pal = palette()
if body and pal then
Voxel3D.draw(body, pal, f.model)
end
if f.tex and f.uv then
local screen = buildScreen(f.uv)
if screen then
Voxel3D.draw(screen, f.tex, f.model)
end
end
end
-- window resize / hot reload: every GPU object here is derived and cheap
function Pokedex.invalidate()
paletteTex, bodyMesh, screenMesh, screenKey = nil, nil, nil, nil
end
return Pokedex