mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 15:10:51 +02:00
787 lines
31 KiB
Lua
787 lines
31 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 mirrors the VOXEL ladder, deliberately: 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 VR = {}
|
|
|
|
-- the row: plain OFF/ON. No hotkey -- the engine's display keys are
|
|
-- spoken for, and a headset is not something to toggle by accident.
|
|
VR.setting = ModSetting.new("vr", "VR", { false, true }, { "OFF", "ON" })
|
|
|
|
-- 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
|
|
|
|
function VR.enabled()
|
|
return VR.supported() and VR.setting:get() == true
|
|
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
|
|
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.
|
|
local fp = FirstPerson.engaged()
|
|
local battle, battleFloor
|
|
if camMode == "battle" then battle, battleFloor = battleStage() end
|
|
if 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.
|
|
local hand = ctl and ctl.handl or nil
|
|
if hand 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
|
|
|
|
local eyes = {}
|
|
for i = 1, 2 do
|
|
local v = views[i]
|
|
eyes[i] = {
|
|
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw),
|
|
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.
|
|
--
|
|
-- 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
|
|
|
|
-- 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")
|
|
VR.setting:setIndex(VR.setting:read() + 1, 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)
|
|
if not ctl then
|
|
releaseInputs()
|
|
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, and the way out of
|
|
-- horde mode while it runs (the rung is locked there, so the click has
|
|
-- nothing else to do, and a headset has no ESCAPE key)
|
|
if ctl.toggleChanged and ctl.toggle then
|
|
if Horde.active then Horde.askExit() 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
|
|
|
|
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 on = VR.enabled()
|
|
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
|
|
|
|
-- 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 ctl = VRXR.input(time)
|
|
driveControls(ctl, dt, FirstPerson.engaged())
|
|
|
|
local worldUp = false
|
|
if should then
|
|
local views = VRXR.locateViews(time)
|
|
if views then
|
|
worldUp = renderWorld(views, ctl)
|
|
end
|
|
end
|
|
local quadPose = updateQuad(worldUp, 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()
|
|
V.require("HordeGun").invalidate()
|
|
V.require("HordeHud").invalidate()
|
|
for k in pairs(fboCache) do fboCache[k] = nil end
|
|
end
|
|
|
|
return VR
|