-- 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" }) -- 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() 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 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 end else Pokedex.clear() 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 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 setGB(inp, "a", ctl.a) setGB(inp, "b", ctl.b) setGB(inp, "start", ctl.start) -- 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)) if ctl.toggleChanged and ctl.toggle then VR.stepView() end -- first person's snap turn: a flick of the right stick steps the view -- 45 degrees, once per flick -- it re-arms only when the stick comes -- back toward centre, so holding it turns exactly once if 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() for k in pairs(fboCache) do fboCache[k] = nil end end return VR