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