DramaticShapeVoxelMod archival mirror · read-only
lib/Pokedex.lua

Pokedex.lua

lib/Pokedex.lua8.9 KB100644242bf7087d
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223
-- 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