mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:40:50 +02:00
224 lines
8.9 KiB
Lua
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
|