Files
DramaticShapeVoxelMod/lib/VR.lua
T
2026-08-06 20:31:01 -04:00

988 lines
40 KiB
Lua

-- VR: the conductor -- one call per game frame that runs the whole
-- headset side, and the row that switches it on.
--
-- The shape of a VR frame, from the pipeline's update hook (which ticks
-- every frame whatever is on the stack, which is exactly what a headset
-- needs -- the world must keep arriving through menus, dialogs and
-- battles):
--
-- poll the runtime's events (begin the session when it says READY)
-- xrWaitFrame <- BLOCKS until the headset wants a frame;
-- with vsync handed off (set to 0 while the
-- session runs) this is what paces the whole
-- app at headset rate, while FixedStep keeps
-- the game's own logic at its 60 Hz
-- locate the two eyes
-- render the world once per eye (VoxelScene.render's `eyes` path:
-- shared shadow map, shared pose capture, per-eye cameras from VRRig)
-- blit each eye canvas into its swapchain image (VRGL)
-- copy the window's front buffer into the UI quad when a menu, dialog,
-- battle or wipe is what the flat screen is showing
-- xrEndFrame with the projection layer and/or the quad
--
-- WHICH VR YOU GET is the row's own rung first (see VR.setting), and only
-- then the VOXEL ladder. STANDARD is the mode described below. The two
-- DIORAMA rungs are one presentation instead of a ladder -- the world is
-- always a model on the table, cut to a square viewport (a ball with a
-- dissolved rim while V-CURVE is on) that the grips pick up, turn and
-- open out, with a staged fight arriving as a floating disc of map.
-- lib/Diorama owns all of that; what this file owns is pointing the
-- mapping at it. DIORAMA-MR is the same with the background keyed green
-- for a mixed-reality capture.
--
-- Within STANDARD, which VR you get mirrors the VOXEL ladder: on the orbit
-- rungs the world is a TABLETOP DIORAMA pinned below and ahead of where
-- your head started -- lean in, walk around it; on 1ST you stand inside
-- at life scale, the HMD steers FirstPerson's yaw and pitch, and FreeMove
-- walks where you look exactly as it does on the flat screen. A STAGED
-- FIGHT takes the camera from both: the headset snaps -- through a fade
-- to black and back -- to the flat battle's own over-the-shoulder seat
-- (VRRig.battleMount), and returns the same way when the fight ends;
-- the 2D battle screen lights up on the POKEDEX in the tracked left
-- hand (lib/Pokedex.lua) and NO floating panel is submitted at all --
-- the fight itself owns the view. The flat window keeps
-- running as the mirror (left eye when the world is up), so menus stay
-- usable at the desk and every existing input keeps working alongside
-- the XR controllers.
--
-- Failure is a status, never a crash: no runtime, no headset, no GL
-- interop, or a mid-session loss all land back on the flat screen with
-- the reason readable off VR.status().
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local Voxel = V.require("VoxelState")
local Voxel3D = V.require("Voxel3D")
local VoxelScene = V.require("VoxelScene")
local FirstPerson = V.require("FirstPerson")
local BattleCam = V.require("BattleCam")
local VRRig = V.require("VRRig")
local VRXR = V.require("VRXR")
local VRGL = V.require("VRGL")
local Pokedex = V.require("Pokedex")
local Diorama = V.require("Diorama")
local VR = {}
-- The row: OFF, and then WHICH VR. No hotkey -- the engine's display keys
-- are spoken for, and a headset is not something to toggle by accident.
--
-- STANDARD what this mod shipped: the headset follows the VOXEL
-- ladder, orbit rungs becoming a tabletop and 1ST standing
-- you inside the world at life size.
-- DIORAMA one presentation instead of a ladder -- the world is
-- always a model on the table, cut to a viewport you can
-- pick up, turn and open out (see lib/Diorama). There is no
-- 2D and no first person in it: both are a different promise
-- about where the player is standing.
-- DIORAMA-MR the same, with the background keyed pure green for a
-- mixed-reality capture.
--
-- `true` is still STANDARD's stored value, deliberately: the row used to be
-- a toggle, and a save that stored it as one must come back on the rung it
-- was left on rather than falling to OFF.
VR.setting = ModSetting.new("vr", "VR",
{ false, true, "diorama", "diorama-mr" },
{ "OFF", "STANDARD", "DIORAMA", "DIORAMA-MR" })
-- How the right stick turns you in first person. OFF is the 45-degree
-- SNAP this mod shipped with and the reason for it is comfort, not
-- taste: a software turn moves the world past a head that did not move,
-- which is vection with no vestibular signal to match it, and it is the
-- single most reliable way to make somebody ill in a headset. A snap
-- gives the inner ear nothing to disagree with.
--
-- But snap turning is not free either -- it costs continuity, and the
-- players who have their sea legs generally want the stick. So it is a
-- row rather than a decision: OFF by default, on for anyone who asks,
-- and the row only exists while there is a headset to use it in.
VR.smoothTurn = ModSetting.new("smoothturn", "SMOOTH TURN",
{ false, true }, { "OFF", "ON" })
-- radians per second at full deflection, with a squared response so the
-- first half of the throw aims and the rest turns -- the same curve
-- FirstPerson gives the flat screen's right stick
VR.SMOOTH_TURN_RATE = 2.2
-- Where the diorama's UI panel floats vs first person's. These are the
-- FALLBACK screens: wherever the pokedex is up and lit -- first
-- person's menus, a battle's 2D scene -- no quad is submitted at all
-- (see updateQuad), and these serve only the diorama and the no-tracked-
-- controller case.
local QUAD_DIORAMA = { pos = { 0, 0.1, -1.0 }, width = 0.8 }
local QUAD_FP = { pos = { 0, 0, -1.4 }, width = 1.1 }
local started = false -- start() succeeded this enablement
local failed = nil -- start() failed; wait for a re-toggle
local wasOn = false
local savedVsync = nil
local fboCache = setmetatable({}, { __mode = "k" }) -- canvas -> GL FBO id
local mirrorSrc = nil -- last left-eye canvas, for the window
local mirrorCanvas = nil
local status = "off"
-- the diorama's live adjustments: the right stick's zoom (a multiplier on
-- the model's size) and the grab-drag's height (metres of world travel)
local zoom = 1
local heightOff = 0
local held = {} -- GB buttons this module is holding down
local lastHandY = nil -- the gripping hand's height, last frame
-- First person's SNAP TURN: the right stick flicked left or right steps
-- the whole XR-to-world mapping 45 degrees at a time (a smooth software
-- turn is the classic comfort mistake -- vection with no vestibular
-- signal; a snap is instant and the head does the rest). The offset
-- turns the mapping itself, so the eyes, the walk direction and the
-- pokedex all agree about which way the world now faces.
local SNAP_TURN = math.rad(45)
local fpYawOff = 0 -- accumulated snaps, radians
local snapArmed = true -- re-arms when the stick returns to centre
local function wrapPi(a)
return (a + math.pi) % (2 * math.pi) - math.pi
end
-- The battle snap, made a FADE rather than a cut: when a fight is staged
-- on the world (or stops being), black rises over both eyes, the camera
-- swaps mounts behind it, and black lifts. A teleport inside VR is the
-- one camera move that should never be SEEN happening -- the world
-- sliding to a new seat reads as the room moving.
local FADE_TIME = 0.35 -- seconds each way: out, then back in
local camMode = "explore" -- "explore" (diorama / 1ST) or "battle"
local fadeAlpha = 0 -- the black over the eyes right now
-- The staged fight to look at, if there is one: arena, floor height.
local function battleStage()
local ok, arena, groundY = pcall(function()
return V.require("OverworldBattle").stage()
end)
if not ok then return nil end
return arena, groundY
end
-- the palette closure the engine hands drawWorld; stashed there (see
-- main.lua) because the VR frame renders from update, where no ctx exists
VR.paletteFor = nil
-- Whether this platform can do VR AT ALL: the shipped loader and the GL
-- interop are Win32 (openxr_loader.dll, wgl), so only Windows qualifies.
-- Everywhere else -- Android above all -- the row is not offered on any
-- menu, and a stored vr=true is ignored rather than read: a save that
-- migrated over from the desktop must not leave a phone trying to start
-- an OpenXR session (or silently forcing the battle rows). Headless runs
-- have no love.system and answer true, which costs nothing: enabling VR
-- there stops at VRXR.start like it always did.
function VR.supported()
local ok, os = pcall(function() return love.system.getOS() end)
if not ok or not os then return true end
return os == "Windows"
end
-- Which VR the row is asking for: "off", "standard", "diorama" or
-- "diorama-mr". The one place the stored value is interpreted -- everything
-- else asks this, so a rung added to the ladder is a change here and
-- nowhere else.
function VR.mode()
if not VR.supported() then return "off" end
local v = VR.setting:get()
if v == true then return "standard" end
if v == "diorama" or v == "diorama-mr" then return v end
return "off"
end
function VR.enabled()
return VR.mode() ~= "off"
end
-- Whether the row is on one of the DIORAMA rungs -- the modes where the
-- world is a model with an edge to it rather than a place to stand in.
function VR.dioramaMode()
local m = VR.mode()
return m == "diorama" or m == "diorama-mr"
end
function VR.active()
return started and VRXR.isRunning()
end
function VR.status()
if not VR.enabled() then return "off" end
if failed then return failed end
return VRXR.status()
end
-- Let go of every input this module was holding: the GB buttons pressed
-- through the overlay path, and the synthetic left stick. Runs when the
-- session ends and whenever a frame has no controller state to read.
local function releaseInputs()
local ok, Game = pcall(require, "src.core.Game")
if not ok or not Game.input then return end
for btn in pairs(held) do
pcall(function() Game.input:overlayReleased(btn) end)
held[btn] = nil
end
pcall(function()
Game.input:gamepadaxis(nil, "leftx", 0)
Game.input:gamepadaxis(nil, "lefty", 0)
end)
lastHandY = nil
end
local function shutdown(reason)
if started then
VRXR.stop()
started = false
end
if savedVsync ~= nil then
pcall(love.window.setVSync, savedVsync)
savedVsync = nil
end
-- the placed camera may still be a VR eye's; the orbit must get the
-- pass back clean
Voxel3D.camera = nil
mirrorSrc = nil
releaseInputs()
BattleCam.still = false
VoxelScene.spriteLean = nil
Pokedex.clear()
-- the horde's gun too: its VR frame is a matrix built from a hand pose,
-- and a stale one left behind would pin the model to wherever the
-- controller was when the session died -- on the FLAT screen, where the
-- view model should have taken over
V.require("HordeGun").clear()
zoom, heightOff = 1, 0
fpYawOff, snapArmed = 0, true
camMode, fadeAlpha = "explore", 0
-- the model goes back on the table where it started: the grab, the turn,
-- the viewport's size and the meshes cut for it
Diorama.reset()
status = reason or "off"
end
VR.shutdown = shutdown -- named for the probe driver
-- Whether the flat screen is showing something the world pass cannot: a
-- menu, a dialog, a battle, a transition wipe. The quad and the pokedex's
-- screen both key on it.
local function uiShowing()
local ok, showing = pcall(function()
local Game = require("src.core.Game")
local top = Game.stack and Game.stack:top()
return top ~= Game.overworld
or (Game.overworld and Game.overworld.transitioning) or false
end)
return ok and showing or false
end
-- ------- the pokedex's screen
--
-- What the device in the hand shows during a battle: the flat window --
-- which IS the 2D battle screen for as long as the battle state draws --
-- copied into a canvas the scene pass can texture with, cropped by UV to
-- the battle's own letterbox so the screen wears the GB frame edge to
-- edge. Menus over the battle (the party, the bag) ride along for free:
-- they are the flat screen too, and reading them on the device in your
-- hand is exactly the point.
local dexCanvas = nil
local function dexScreen()
local ok, out = pcall(function()
local ww, wh = love.graphics.getPixelDimensions()
if not (ww and ww > 0 and wh and wh > 0) then return nil end
if not (dexCanvas and dexCanvas:getWidth() == ww
and dexCanvas:getHeight() == wh) then
dexCanvas = love.graphics.newCanvas(ww, wh)
pcall(dexCanvas.setFilter, dexCanvas, "nearest", "nearest")
end
local fbo = fboCache[dexCanvas]
if not fbo then
fbo = VRGL.canvasFBO(dexCanvas)
fboCache[dexCanvas] = fbo
end
if not (fbo and VRGL.copyFrontToCanvas(fbo, ww, wh)) then return nil end
local BattleScene = V.require("BattleScene")
local lx, ly, s = BattleScene.letterbox()
return { dexCanvas,
lx / ww, ly / wh,
(lx + BattleScene.GB_W * s) / ww,
(ly + BattleScene.GB_H * s) / wh }
end)
return ok and out or nil
end
-- ------- the world, once per eye
local function renderWorld(views, ctl)
local ok, Game = pcall(require, "src.core.Game")
local ow = ok and Game.overworld or nil
if not (ow and ow.map and ow.camera and Voxel.active()
and Voxel3D.available()) then
return false
end
local vw, vh = 320, 288
pcall(function() vw, vh = Game.renderer:worldViewSize() end)
-- Whatever the camera does, the CARDS hold the top rung's near-upright
-- lean: a head that roams has no one pitch for them to match, and 75
-- degrees is the pose that reads as "standing" from anywhere. Cleared
-- on shutdown, so the flat screen leans with the rung as ever.
VoxelScene.spriteLean = math.rad(75)
local pivot, anchor, scale, mountYaw
-- Either free-roam rung puts the headset in the player's head: 3RD's boom
-- is a FLAT-SCREEN framing device, and a headset that stands its wearer
-- three cells behind their own body is a well-known way to make people
-- ill. The rung still changes the walk and the cards the same way; only
-- the eye stays where a head belongs.
--
-- A DIORAMA mode never does either: the world is a model on the table
-- whatever the rung says, so first person is refused here rather than
-- being made to work at a scale it does not mean.
local dio = VR.dioramaMode()
local fp = (not dio) and FirstPerson.engaged()
local battle, battleFloor
if camMode == "battle" then battle, battleFloor = battleStage() end
if dio then
-- ------- the diorama modes
--
-- The model presents exactly as the standard view frames it -- the
-- pivot VIEW_DIST away along the rung's angle, at the scale that
-- reproduces that framing -- and then everything the player has done
-- to it goes on top: the carry, the turn, the stick's zoom.
--
-- A STAGED FIGHT does not move the head here (that is the standard
-- mode's over-the-shoulder seat, and it is a first-person answer):
-- the MODEL re-centres on the arena and the viewport becomes a
-- vertical pillar about it, so the fight arrives as a disc of map
-- lifted out of the world and left floating on the table.
-- what the model is FRAMED to fill: the view the flat screen would
-- have shown ordinarily, and the DISC itself while a fight is staged
-- -- a disc left at map scale is a coin on a table across the room.
local frame = vh
if battle then
pivot = VRRig.dioramaPivot(battle.mid[1], battle.mid[2])
local cut = Diorama.pillar(battle)
if cut then frame = cut.r * 2.6 end
else
pivot = VRRig.dioramaPivot(ow.camera.x + vw / 2, ow.camera.y + vh / 2)
Diorama.viewport(pivot[1], pivot[3], vh)
end
anchor = VRRig.dioramaAnchor(Voxel.angle, Diorama.offset)
scale = VRRig.dioramaScale(frame, Voxel.FOCAL) / zoom
-- the hand-turn, and -- while a fight is staged -- the arena's own
-- quarter turn taken back out, so a turned arena arrives on the table
-- facing the head rather than lying across it (Diorama.battleYaw)
local dioYaw = battle and Diorama.battleYaw(battle) or Diorama.yaw
if dioYaw ~= 0 then mountYaw = dioYaw end
elseif battle then
-- the over-the-shoulder seat the flat battle shot stands in, pulled
-- close enough for a headset's own lens (see VRRig.battleMount), at
-- life scale, turned to face the arena
local rec = BattleCam.rig(battle, battleFloor)
pivot, mountYaw = VRRig.battleMount(rec.eye, rec.focus)
anchor = { 0, 0, 0 }
scale = VRRig.FP_SCALE
elseif fp then
local p = ow.player
local gh = 0
pcall(function() gh = VoxelScene.groundAt(ow.map, p.cellX, p.cellY) end)
pivot = VRRig.fpPivot(p.px, p.py, gh, FirstPerson.EYE_HEIGHT)
anchor = { 0, 0, 0 }
scale = VRRig.FP_SCALE
-- the snap turn is a yaw on the MAPPING, same seam the battle mount
-- turns through
if fpYawOff ~= 0 then mountYaw = fpYawOff end
-- the HMD is the head: its yaw and pitch (plus the snaps) become
-- FirstPerson's, so FreeMove walks where you look and A talks to
-- what you face
local yaw, pitch = VRRig.headYawPitch(views[1].pose.quat)
FirstPerson.yaw = wrapPi(yaw + fpYawOff)
FirstPerson.pitch = math.max(FirstPerson.PITCH_UP,
math.min(FirstPerson.PITCH_DOWN, pitch))
else
-- The table presents the world exactly as the flat screen does at
-- rest: the pivot sits VIEW_DIST away along the RUNG'S own angle
-- (stepping rungs re-tilts the model, easing with the rung tween),
-- at the scale that reproduces the flat framing -- then the player's
-- own adjustments go on top: the stick's zoom, the grip's height.
pivot = VRRig.dioramaPivot(ow.camera.x + vw / 2, ow.camera.y + vh / 2)
anchor = VRRig.dioramaAnchor(Voxel.angle, heightOff)
scale = VRRig.dioramaScale(vh, Voxel.FOCAL) / zoom
end
-- The pokedex, on the tracked left hand, under this very mapping --
-- but only where it earns its keep: FIRST PERSON, where its screen is
-- every menu, dialog and wipe the flat screen shows (and the floating
-- billboard is retired outright -- see updateQuad), and the BATTLE
-- seat, where its screen is the fight's own 2D scene. The diorama
-- does without: a hand-sized device hovering over a tabletop town is
-- clutter, and the panel serves there. No hand tracked, no device.
-- (`not dio` for the same reason the diorama never had one: a hand-sized
-- device hovering over a tabletop town is clutter, and that is as true
-- of a tabletop FIGHT -- the panel serves both.)
local hand = ctl and ctl.handl or nil
if hand and not dio and (battle or fp) then
Pokedex.place(hand, pivot, anchor, scale, mountYaw)
if uiShowing() then
local scr = dexScreen()
if scr then
Pokedex.screen(scr[1], scr[2], scr[3], scr[4], scr[5])
end
elseif V.require("Horde").active then
-- HORDE MODE's readout, on the device already in the player's left
-- hand. It cannot be a flat overlay: the eye buffers have
-- ASYMMETRIC frusta, so the same canvas pixel is a different ANGLE
-- in each eye and a 2D HUD drawn into both tears down the middle.
-- The Pokedex is real geometry both eyes see from their own
-- position, so the stereo is correct by construction -- and it is
-- already tracked, already lit, and already the thing this mod
-- puts information on. (The gun wore it briefly and that was
-- worse: a screen on the slide sits exactly where the iron sights
-- need to be looked through.)
--
-- The UV rect goes over the usual way up: v = 0 at the TOP, which
-- is how the device's screen quad reads every other texture it
-- wears. An inverted rect was tried first, on the theory that a
-- self-drawn canvas samples from the bottom -- it does not here,
-- and it stood the readout on its head.
local tex = V.require("HordeHud").panelTexture()
if tex then Pokedex.screen(tex, 0, 0, 1, 1) end
end
else
Pokedex.clear()
end
-- and the horde's gun on the tracked RIGHT hand, under the same
-- mapping. The AIM pose where the runtime offers one -- the barrel
-- should point where the player is pointing, not along their wrist --
-- and the grip pose as the fallback. Placed here rather than in the
-- draw because the shot is traced down the model's own axis, so the
-- matrix has to exist before anything can be hit with it.
do
local HordeGun = V.require("HordeGun")
local right = ctl and (ctl.aimr or ctl.handr) or nil
if right and fp and not battle and V.require("Horde").active then
HordeGun.place(right, pivot, anchor, scale, mountYaw)
else
HordeGun.clear()
end
end
-- THE WORLD CURVE, for the diorama modes alone. Standing inside a bent
-- world is what first person declines on the flat screen too, and the
-- battle mount is a placed shot -- but a diorama is a model being looked
-- AT, so the bend turns it into a little globe curling over its own
-- horizon, which is the whole point of the throw the left stick's click
-- makes. Measured against the FLAT view height, so a rung's bend is the
-- same bend the flat screen would have drawn.
--
-- It bends about the scene centre, which for these eyes is the pivot --
-- the model's own middle -- so the globe is centred on the model rather
-- than on wherever a head happens to be standing.
local curveK = dio and V.require("WorldCurve").k(vh) or 0
local eyes = {}
for i = 1, 2 do
local v = views[i]
eyes[i] = {
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw,
curveK),
w = v.w, h = v.h,
slot = i == 1 and "vrL" or "vrR",
-- the battle seat is a placed shot, not the first-person rig: the
-- cards keep their stage lean rather than yawing at this eye, and
-- the player's own card stays visible in it
adopt = not battle,
}
end
eyes.cx, eyes.cy = pivot[1], pivot[3]
local okR, canvases = pcall(VoxelScene.render, ow, 0, 0, vw, vh,
VR.paletteFor, eyes)
if not (okR and type(canvases) == "table" and canvases[1] and canvases[2])
then
return false
end
-- the snap's fade, over the finished eyes: plain black at this moment's
-- strength, drawn before the blit so the headset never sees the swap.
-- A full-frame fill is the ONE 2D thing that is safe to draw into an
-- eye buffer -- it covers everything, so it does not matter that the
-- two frusta disagree about where any given pixel points.
if fadeAlpha > 0 then
pcall(function()
for i = 1, 2 do
local c = canvases[i]
love.graphics.setCanvas(c)
love.graphics.setColor(0, 0, 0, math.min(1, fadeAlpha))
love.graphics.rectangle("fill", 0, 0, c:getWidth(), c:getHeight())
end
love.graphics.setCanvas()
love.graphics.setColor(1, 1, 1, 1)
end)
end
for i = 1, 2 do
local canvas = canvases[i]
local tex, tw, th = VRXR.acquireEye(i)
if tex then
local fbo = fboCache[canvas]
if not fbo then
fbo = VRGL.canvasFBO(canvas)
fboCache[canvas] = fbo
end
if fbo then
VRGL.blitToTexture(fbo, canvas:getWidth(), canvas:getHeight(),
tex, tw, th)
end
end
VRXR.releaseEye(i)
end
mirrorSrc = canvases[1]
return true
end
-- ------- the UI panel
-- Whether the flat screen is showing something the world pass cannot: a
-- menu, a dialog, a battle, a transition wipe -- or everything, when the
-- world pass is off entirely.
local function wantQuad(worldUp)
if not worldUp then return true end
return uiShowing()
end
local function updateQuad(worldUp, fp)
if not wantQuad(worldUp) then return nil end
-- Wherever the pokedex is up and lit -- first person's menus, the
-- battle seat's 2D fight -- it IS the screen, and no floating
-- billboard is submitted at all. (No tracked left hand still gets
-- the panel: the UI must be readable somewhere.)
if Pokedex.frame and Pokedex.frame.tex then return nil end
local tex, qw, qh = VRXR.acquireQuad()
if not tex then return nil end
local ww, wh = qw, qh
pcall(function() ww, wh = love.graphics.getPixelDimensions() end)
-- The panel wears the GB FRAME, not the window: everything the flat
-- screen has to say lives in the 160x144 letterbox (the world around
-- it is just the mirror's picture). The frame region is blitted OUT
-- of the window and SCALED into the swapchain image -- never copied
-- pixel-for-pixel, because the swapchain's size is fixed at session
-- start and a fullscreened window outgrows it, running the frame (and
-- the START menu flush with its right edge) off the copy. Scaled, the
-- panel shows the identical picture at the identical ratio whatever
-- size the window is. Source coordinates are GL's, origin bottom-left.
local crop = nil
local copied = false
pcall(function()
local BattleScene = V.require("BattleScene")
local lx, ly, s = BattleScene.letterbox()
local wpx = math.ceil(BattleScene.GB_W * s)
local hpx = math.ceil(BattleScene.GB_H * s)
local sx = math.max(0, math.floor(lx))
local sy = math.max(0, math.floor(wh - ly - hpx))
wpx = math.min(wpx, ww - sx)
hpx = math.min(hpx, wh - sy)
if wpx < 1 or hpx < 1 then return end
-- fitted to the swapchain image at the REGION's own aspect: the
-- crop then presents exactly that rect, so the panel's shape is the
-- GB frame's at any window and any swapchain size
local fit = math.min(qw / wpx, qh / hpx)
local dw = math.max(1, math.floor(wpx * fit))
local dh = math.max(1, math.floor(hpx * fit))
if VRGL.copyFrontRegionToTexture(tex, sx, sy, wpx, hpx, dw, dh) then
copied = true
crop = { 0, 0, dw, dh }
end
end)
if not copied then
-- no letterbox to cut (or the blit refused): the old whole-window
-- copy, clamped, is still a readable panel
VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh))
end
VRXR.releaseQuad()
local base = fp and QUAD_FP or QUAD_DIORAMA
if not crop then return base end
return { pos = base.pos, width = base.width, crop = crop }
end
-- ------- the controllers
--
-- The mapping the mod ships (rebindable in the runtime's own UI):
--
-- both modes left stick moves (through the engine's own stick path,
-- so it grid-walks the diorama and free-walks 1ST);
-- A/B are A/B; either trigger is START; clicking the
-- LEFT stick steps the VOXEL angle ladder exactly as
-- the "3" key (and the pad's SELECT) does.
-- 1ST only right stick left/right SNAP-TURNS 45 degrees a flick.
-- diorama only right stick up/down zooms the model; squeezing a grip
-- and moving that hand up or down drags the whole table
-- with it.
--
-- The DIORAMA modes rebind two of those, because in them there is no
-- ladder to step and no table-height to be the only thing worth dragging:
--
-- left stick click throws V-CURVE to its top rung and back.
-- grips one carries the model anywhere in the room; both
-- turn it and open the viewport out (Diorama.gesture).
--
-- Leaving VR is the VR row's job alone (OPTIONS menu or the manager) --
-- no controller button does it. VR.leave below stays as the API for it.
-- The left stick click makes EXACTLY the step the "3" key makes: one
-- rung up the VOXEL angle ladder, wrapping, stepping over FULL, clearing
-- TILT and GBC FX in the save -- by calling the very function the key
-- and the pad's SELECT button already share. main.lua installs it below
-- (cycleVoxel is a local of that file); the free-roam gate is the
-- registry's own, inside it, so a click over a menu or mid-warp is a
-- no-op exactly like the key.
VR.cycleVoxel = nil -- cycleVoxel(game), set by main.lua
function VR.stepView()
pcall(function()
if not VR.cycleVoxel then return end
VR.cycleVoxel(require("src.core.Game"))
end)
end
-- Put the VOXEL ladder on a given rung, for the one caller that needs to
-- rather than to step: a DIORAMA mode holding the ladder off 2D and off
-- both free-roam rungs (see dioramaRung). Handed over by main.lua next to
-- cycleVoxel and for the same reason.
VR.setVoxelLevel = nil -- setVoxelLevel(game, level), set by main.lua
-- The rung a diorama mode holds the ladder on when it finds it somewhere
-- the mode cannot present: 35 degrees, the standard view's own angle.
VR.DIORAMA_RUNG = 3
-- 2D is not a diorama and neither is standing inside the world, so while a
-- diorama mode is live the ladder is held on an orbit rung. Cheap enough to
-- ask every frame: it is a table read and, almost always, no write.
local function dioramaRung()
pcall(function()
if not VR.setVoxelLevel then return end
local Pipelines = require("src.render.Pipelines")
local level = Pipelines.level("voxel") or 0
if level == 0 or Voxel.isFreeCam(level) then
VR.setVoxelLevel(require("src.core.Game"), VR.DIORAMA_RUNG)
end
end)
end
-- The V-CURVE row, thrown to its top rung and back -- what the left stick's
-- click does in a diorama, where there is no ladder for it to step.
--
-- A toggle rather than a cycle, because in a headset the curve is not a
-- taste setting with four values: it is the one control that decides
-- whether the model is a flat slab of map or a little world curling away
-- over its own horizon, and the player wants to see both, now, without
-- counting clicks. The rung it was on is remembered so the click gives it
-- back rather than dropping the row to OFF.
--
-- It changes the CUT with it (see lib/Diorama): flat world, square box,
-- hard edge; curved world, ball, dissolve. One click swaps the whole
-- reading of the model, which is why it is the click worth having here.
local curveWas = nil
function VR.toggleCurve()
pcall(function()
local Game = require("src.core.Game")
local WorldCurve = V.require("WorldCurve")
local top = WorldCurve.setting.values[#WorldCurve.setting.values]
if WorldCurve.setting:get() == top then
WorldCurve.setting:setValue(curveWas or WorldCurve.setting.values[1],
Game)
curveWas = nil
else
curveWas = WorldCurve.setting:get()
WorldCurve.setting:setValue(top, Game)
end
end)
end
-- Leave VR: the VR row toggled back off and persisted, exactly as if
-- stepped on the OPTIONS menu, so the next update tears the session down
-- and the flat screen takes the picture back. Deliberately bound to NO
-- controller button (a click that ejects you from the headset is a trap
-- mid-fight); kept as the one programmatic door out.
function VR.leave()
pcall(function()
local Game = require("src.core.Game")
-- OFF by VALUE, not by stepping the row: the row is a ladder now, and
-- one step off STANDARD is DIORAMA rather than the way out
VR.setting:setValue(false, Game)
end)
end
local function setGB(inp, btn, down)
if down and not held[btn] then
held[btn] = true
inp:overlayPressed(btn)
elseif not down and held[btn] then
held[btn] = nil
inp:overlayReleased(btn)
end
end
local function driveControls(ctl, dt, fp, dio)
if not ctl then
releaseInputs()
Diorama.releaseGrab()
return
end
local ok, Game = pcall(require, "src.core.Game")
if not (ok and Game.input) then return end
local inp = Game.input
-- HORDE MODE re-reads the right hand as a weapon: the trigger fires
-- (its own OpenXR action, suggested alongside START on the same input
-- -- see VRXR.setupInput), and B reloads. START is dropped rather than
-- forwarded, because the mode does not pause. Everything else -- the
-- stick's walk, the snap turn, A -- keeps working, so the player can
-- still move and look while they are being chased.
local Horde = V.require("Horde")
if Horde.playing() then
local Gun = V.require("HordeGun")
if ctl.fireChanged and ctl.fire then Gun.fire() end
if ctl.bChanged and ctl.b then Gun.reload() end
setGB(inp, "a", ctl.a)
setGB(inp, "b", false)
setGB(inp, "start", false)
else
setGB(inp, "a", ctl.a)
setGB(inp, "b", ctl.b)
setGB(inp, "start", ctl.start)
end
-- the left stick, through the engine's OWN stick handler: it quantises
-- to the grid d-pad for the diorama, and FirstPerson.moveVector reads
-- the same raw pair for the free walk. OpenXR's +Y is up; the engine's
-- lefty is +down.
inp:gamepadaxis(nil, "leftx", ctl.moveX or 0)
inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0))
-- the left stick click: the VOXEL ladder ordinarily, the V-CURVE throw in
-- a diorama (where the ladder is held on one rung and the click would
-- otherwise do nothing), and the way out of horde mode while it runs (the
-- rung is locked there too, and a headset has no ESCAPE key)
if ctl.toggleChanged and ctl.toggle then
if Horde.active then
Horde.askExit()
elseif dio then
VR.toggleCurve()
else
VR.stepView()
end
end
-- first person's turn on the right stick. SMOOTH TURN ON makes it a
-- rate -- hold and the world rotates under you -- and OFF (the
-- default) makes it a 45-degree snap per flick: see the row's own
-- reasoning where it is declared. Either way the offset turns the
-- MAPPING, so the eyes, the walk direction, the pokedex and the gun
-- all agree about which way the world now faces.
if fp and camMode ~= "battle" and VR.smoothTurn:get() == true then
local sx = ctl.lookX or 0
local a = math.abs(sx)
if a > 0.2 then
a = (a - 0.2) / 0.8
-- increasing yaw turns LEFT in this mod's compass, so a stick
-- pushed right subtracts -- the same sign the snap below uses
fpYawOff = wrapPi(fpYawOff
- (sx > 0 and 1 or -1) * a * a
* VR.SMOOTH_TURN_RATE * (dt or 0))
end
snapArmed = true -- so a switch back to snap mid-flick re-arms
elseif fp and camMode ~= "battle" then
local sx = ctl.lookX or 0
if math.abs(sx) > 0.65 then
if snapArmed then
snapArmed = false
-- increasing yaw turns LEFT in this mod's compass, so a stick
-- pushed right subtracts
fpYawOff = wrapPi(fpYawOff + (sx > 0 and -SNAP_TURN or SNAP_TURN))
end
elseif math.abs(sx) < 0.35 then
snapArmed = true
end
end
-- THE DIORAMA'S GRIPS take the model itself: one hand carries it through
-- the room, both turn it and open the viewport out (see Diorama.gesture).
-- The stick's zoom still sizes the model under all of that -- the two
-- are different questions, "how big is it" and "how much of it is there".
if dio then
lastHandY = nil
local zy = ctl.lookY or 0
if math.abs(zy) > 0.15 then
zoom = math.max(0.35, math.min(4, zoom * math.exp(zy * (dt or 0) * 1.6)))
end
Diorama.gesture(ctl)
return
end
if not fp and camMode ~= "battle" then
local zy = ctl.lookY or 0
if math.abs(zy) > 0.15 then
zoom = math.max(0.35, math.min(4, zoom * math.exp(zy * (dt or 0) * 1.6)))
end
-- the grab-drag: while a grip is squeezed, the table follows that
-- hand's height, metre for metre
local gl, gr = ctl.gripL or 0, ctl.gripR or 0
local y = (gr >= gl) and ctl.handrY or ctl.handlY
if math.max(gl, gr) > 0.6 and y then
if lastHandY then
heightOff = math.max(-1.5, math.min(1.5, heightOff + (y - lastHandY)))
end
lastHandY = y
else
lastHandY = nil
end
else
lastHandY = nil
end
end
-- ------- the per-frame drive
function VR.update(dt)
local mode = VR.mode()
local on = mode ~= "off"
if not on then
if wasOn then
shutdown("off")
failed = nil
end
wasOn = false
return
end
if not wasOn then failed = nil end -- a fresh toggle earns a fresh try
wasOn = true
if failed then return end
if not started then
local qw, qh = 1024, 768
pcall(function() qw, qh = love.graphics.getPixelDimensions() end)
if VRXR.start(qw, qh) then
started = true
status = "session created"
print("[DRAMATIC_SHAPE] VR: " .. VRXR.status())
else
failed = VRXR.status()
print("[DRAMATIC_SHAPE] VR unavailable: " .. failed
.. " -- fix that, then toggle the VR row to retry")
return
end
end
if not VRXR.poll() then
-- the runtime took the session away (headset off, runtime shut down)
shutdown("session lost")
failed = "session lost -- toggle VR off and on to retry"
return
end
if not VRXR.isRunning() then return end
-- the headset paces the app now; vsync would fight it
if savedVsync == nil then
savedVsync = 1
pcall(function() savedVsync = love.window.getVSync() end)
pcall(love.window.setVSync, 0)
end
-- Which VR this frame is, before anything reads it: the diorama's own
-- fields (the viewport, the chroma key) are open for the length of the
-- frame and shut with the session. The rung guard rides it -- there is
-- no 2D diorama and no first-person one.
if Diorama.begin(mode) then dioramaRung() end
-- the battle camera holds still for as long as a headset is watching:
-- its drift is a flat screen's depth cue, and a swaying picture inside
-- VR reads as the world lurching
BattleCam.still = true
-- The battle snap's fade: while the camera the frame WANTS is not the
-- one it is showing, black rises; at full black the mount swaps; then
-- black lifts. Driven here, on game time, so a fight that ends during
-- the fade just turns it around.
local want = battleStage() and "battle" or "explore"
if want ~= camMode then
fadeAlpha = math.min(1, fadeAlpha + (dt or 0) / FADE_TIME)
if fadeAlpha >= 1 then camMode = want end
else
fadeAlpha = math.max(0, fadeAlpha - (dt or 0) / FADE_TIME)
end
local time, should = VRXR.waitFrame()
if not time then return end
-- the controllers, before the world renders: the frame the toggle
-- flips rungs on should be the frame that renders the new rig. The
-- state is kept in hand for renderWorld too -- the pokedex stands on
-- the same frame's left-hand pose.
local dio = VR.dioramaMode()
local ctl = VRXR.input(time)
driveControls(ctl, dt, (not dio) and FirstPerson.engaged(), dio)
local worldUp = false
if should then
local views = VRXR.locateViews(time)
if views then
worldUp = renderWorld(views, ctl)
end
end
-- the diorama's panel is the tabletop one whatever the rung says: there
-- is no first person in the mode to float it closer for
local quadPose = updateQuad(worldUp, (not dio) and FirstPerson.engaged())
VRXR.endFrame(time, worldUp or nil, quadPose)
end
-- ------- the window while a headset owns the picture
-- The flat window becomes the mirror: the left eye, fitted to the window.
-- Returns nil when there is nothing to mirror (the caller draws the flat
-- path as ever).
function VR.mirror(sw, sh)
if not (VR.active() and mirrorSrc) then return nil end
if not (mirrorCanvas and mirrorCanvas:getWidth() == sw
and mirrorCanvas:getHeight() == sh) then
local ok, c = pcall(love.graphics.newCanvas, sw, sh)
if not ok then return nil end
mirrorCanvas = c
end
local ok = pcall(function()
love.graphics.setCanvas(mirrorCanvas)
love.graphics.clear(0, 0, 0, 1)
local mw, mh = mirrorSrc:getDimensions()
local s = math.min(sw / mw, sh / mh)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mirrorSrc, (sw - mw * s) / 2, (sh - mh * s) / 2, 0, s, s)
love.graphics.setCanvas()
end)
pcall(love.graphics.setCanvas)
return ok and mirrorCanvas or nil
end
-- window resize, hot reload: the eye canvases are Voxel3D's and go with
-- its invalidate; ours is the mirror and the FBO ids learned from dead
-- canvases
function VR.invalidate()
if mirrorCanvas and mirrorCanvas.release then
pcall(mirrorCanvas.release, mirrorCanvas)
end
mirrorCanvas, mirrorSrc = nil, nil
if dexCanvas and dexCanvas.release then pcall(dexCanvas.release, dexCanvas) end
dexCanvas = nil
Pokedex.invalidate()
Diorama.invalidate() -- the base's mesh and its cave-floor texture
V.require("HordeGun").invalidate()
V.require("HordeHud").invalidate()
for k in pairs(fboCache) do fboCache[k] = nil end
end
return VR