From 44f729e8c2f253e0304b08a8b85ba1b07e8b7ef0 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sun, 2 Aug 2026 11:26:54 -0400 Subject: [PATCH] battle effect fixes --- CHANGELOG.md | 114 ++++++++++-- README.md | 31 +++- lib/BattleScene.lua | 85 +++++++++ lib/OverworldBattle.lua | 120 ++++++++++++- lib/Sky.lua | 93 ++++++++-- lib/VR.lua | 319 ++++++++++++++++++++++++++++------ lib/VRGL.lua | 26 +++ lib/VRRig.lua | 72 +++++++- lib/VRXR.lua | 37 +++- lib/Voxel3D.lua | 102 ++++++++++- lib/VoxelScene.lua | 56 +++++- main.lua | 29 +++- mod.card | 10 +- tests/dramatic_shape_test.lua | 139 +++++++++++++++ 14 files changed, 1110 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1049726..e71d23e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,25 +31,111 @@ 1ST), **A/B** are A/B, **either trigger** is START, and **clicking the left stick** toggles first/third person (returning to the orbit rung you left). In the diorama: **right stick up/down** zooms the - model, **clicking the right stick** cycles the viewing angle through - the orbit rungs (the table re-tilts on the rung's own tween), and - **squeezing a grip** while moving that hand up or down drags the - whole table with it. + model, and **squeezing a grip** while moving that hand up or down + drags the whole table with it. No controller button leaves VR -- + both directions belong to the VR row alone, so no mid-fight click + can eject you from the headset. - - **Battles happen ON the world.** While a staged fight runs, the - headset keeps looking at the map -- both mons stand on their arena - cells in the VR eyes' own view, yawed per eye, casting real shadows, - wearing the hit flash -- while the battle UI reads from the floating - panel. (The flat screen keeps its composed battle shot, untouched.) - And the battle camera's parallax drift holds still while a headset - is watching: the sway is a flat screen's depth cue, and inside VR it - read as the world lurching -- most of all on the floating panel. + - **Battles happen ON the world -- from the flat game's own seat.** + When a staged fight starts, the headset fades to black and comes + back seated in the flat battle's over-the-shoulder shot: on the same + camera line (your mon near-left, the foe far-right), pulled in to + the wide rig's standing distance at life scale, turned to face the + arena -- and fades back to wherever you were when the fight ends. + Both mons stand on their arena cells in the VR eyes' own view, yawed + per eye, casting real shadows, wearing the hit flash -- and the MOVE + ANIMATIONS play out there with them: the engine's own effects layer, + caught on a canvas and stood on a billboard that faces the eye the + way the mon cards do (effects are 2D drawings, and a drawing must + face the eye that is looking), with the classic layout's two slot + marks pinned where each arena cell lands on that plane along the + eye's own ray -- so a burst authored at a slot sits exactly over the + mon standing in for it, per eye, and a projectile crossing the frame + crosses the arena. (The flat screen keeps its + composed battle shot, untouched.) And the battle camera's parallax + drift holds still while a headset is watching: the sway is a flat + screen's depth cue, and inside VR it read as the world lurching. + + - **A voxel POKEDEX along your left controller.** A hand-authored + voxel model of the series' own field guide -- red slab, lens, LEDs, + hinge, d-pad, dark screen bezel -- laid flush along the left + controller's grip pose (a full quarter turn forward, so holding the + controller is holding the device) through the same XR-to-world + mapping as the eyes, at its real hand size wherever the camera is. + It rides in FIRST PERSON and in the BATTLE seat; the diorama does + without it -- a hand-sized device hovering over a tabletop town is + clutter, and the floating panel serves there. In first person its + screen carries EVERYTHING the flat screen shows -- menus, dialogs, + shops, wipes -- and the floating billboard is retired outright: + raise your hand to read, lower it to play. In a staged fight the + screen is the 2D battle -- text, menus, HP bars, and any party or + bag screen opened over it -- and there too the floating billboard is + gone entirely: the fight owns the view, the reading is in the hand. + (No tracked left controller still gets the floating panel -- the UI + must be readable somewhere.) Drawn by the scene's own pass with real + depth, per eye; dark when nothing is showing. + + - **The floating panel wears the GB frame.** The quad used to show + the whole window -- monitor-wide, mostly mirror -- and now crops to + the 160x144 letterbox where everything the flat screen has to say + actually lives, so the panel presents near-square (10:9) at 1:1 + pixel aspect. To keep a battle's HUDs inside that frame, the HUD + blocks stay in their classic GB slots for as long as a headset is + live instead of snapping out to the window's edges. + + - **The sky is anchored in space.** On the flat screen the band + gradient hangs off the frame; inside a headset that meant the sky + (and the sun and moon with it) rode the player's head. The VR eyes + now hang the gradient over a fixed slice of elevation above the + world's real horizon and drop the fixed-slice fallback entirely, so + tilting your head slides the frame across a sky that stays put -- + and the discs, already projected through each eye's true camera, + stand still over their own azimuth. The horizon holds LEVEL under a + rolled head too: the whole painting runs along the horizon's own + projected axis rather than the canvas's rows, so tipping your head + tips the frame across the horizon instead of hinging the horizon + with your ears -- and the discs' cell art is drawn in that same + frame, so a tipped head no longer spins the moon's face in place. + Under PITCH the gradient stays put as well: the band span's far end + is the 55-degree direction's own projection through the eye's + frustum rather than a pixels-per-radian estimate -- a perspective's + rows are tan-spaced, not angle-spaced, and the linear guess let the + bands slide as the horizon crossed the frame. + + - **The VR row exists only where VR can.** Off Windows -- the Android + build above all -- the row is absent from the OPTIONS menu and the + mod manager's page both (the loader and the GL interop are Win32), + and a stored vr=true that migrated over in a save is ignored instead + of read, so a phone never tries to start a session or force the + battle rows. + + - **VR owns the battle rows.** While the VR row is ON, staged battles + are REQUIRED (3D-BTL answers ON whatever it was set to) and BACK + SPRITES is held OFF -- the battle seat, the pokedex screen and the + effects plane all assume both mons standing on the world -- and both + rows leave the OPTIONS menu for the duration, because a switch that + decides nothing reads as broken. Both come back, at their stored + values, the moment VR goes off. + + - **First person snap-turns.** Flicking the right stick left or right + steps the view 45 degrees, once per flick (it re-arms at centre) -- + a snap rather than a smooth spin, because smooth software yaw is + the classic VR comfort mistake. The turn steps the XR-to-world + mapping itself, so the eyes, the walk direction and the pokedex in + your hand all agree about which way the world now faces. + + - **The cast holds one pose for the headset.** Sprite cards lean back + by the camera pitch on the flat screen; a head that roams the table + has no one pitch to match, so every VR frame leans the cards at the + top rung's near-upright 75 degrees instead -- whatever orbit rung + the ladder is on -- and the flat screen keeps leaning with the rung. - **Menus, dialogs, battles and wipes float on a panel** (an OpenXR quad layer fed from the window), so everything the flat screen can show, the headset can read. The window itself becomes the VR mirror - -- the left eye, fitted to the window -- and every existing input - keeps working; v1 deliberately has no XR controller bindings. + -- the left eye, fitted to the window -- and every existing + keyboard, mouse and pad input keeps working alongside the XR + controllers. - The two eyes share one shadow map, one pose capture and one glint step per frame (VoxelScene.render's `eyes` path), so they can never diff --git a/README.md b/README.md index 8cf6962..5224f77 100644 --- a/README.md +++ b/README.md @@ -20,4 +20,33 @@ menu. | `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | | the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone | -| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | \ No newline at end of file +| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | + +## VR + +The **VR** options row (OFF / ON, off by default) drives a PCVR headset +through OpenXR on Windows — SteamVR, Oculus or WMR. On the orbit rungs +the world is a head-tracked tabletop diorama at the rung's own angle; on +**1ST** you stand inside it at life size; a battle snaps you (through a +fade) into the game's own over-the-shoulder shot. Menus float on a +panel wearing the Game Boy frame, and in first person they show up on +the **Pokédex in your left hand** instead. Turning VR off is the VR +row's job — no controller button does it. + +### VR controls + +Suggested onto Touch, Index and WMR controllers (rebindable in the +runtime's own binding UI); pad, keyboard and mouse all keep working +alongside. + +| control | does | +| --- | --- | +| left stick | move — grid-walks the diorama, free-walks 1ST | +| A / B (X / Y on the left hand) | A / B | +| either trigger | START | +| left stick click | toggle first / third person | +| right stick up / down | *diorama only* — zoom the model | +| right stick left / right | *1ST only* — snap-turn 45° | +| grip squeeze + raise / lower that hand | *diorama only* — drag the table's height | +| head | *1ST and battles* — look; FreeMove walks where you look | +| left hand | *1ST and battles* — the Pokédex: menus, dialogs and the 2D battle screen on its screen | \ No newline at end of file diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 3938f89..19adad0 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -210,6 +210,91 @@ end BattleScene.monCards = monCards +-- The MOVE-ANIMATION layer's place in the world: a BILLBOARD facing the +-- eye, for the GB-frame effects texture OverworldBattle.animTexture +-- renders (the engine's own drawAnimLayer, caught on a canvas). +-- +-- Effects are 2D drawings like the pics, and the pics' answer holds for +-- them too: a drawing must FACE the eye that is looking (the mon cards +-- yaw toward it per eye -- see monMatrix). So the frame stands on the +-- arena's midpoint, yawed at the eye like the cards are, and the classic +-- layout's two slot marks are pinned where each CELL lands on that plane +-- along this very eye's own ray -- so from the eye that is looking, a +-- burst authored at a slot sits exactly over the mon standing in for it, +-- and a projectile crossing the frame crosses the arena. The vertical +-- scale is the mon cards' own (FULL_W / FULL_PIC), so an effect is sized +-- like the pics it plays over. +-- +-- An eye standing (nearly) ON the arena's axis sees the two cells in +-- line and the pinning degenerates; the frame then falls back to the +-- fixed plane through both cells, which that eye views edge-on anyway. +-- +-- Reads Voxel3D.eye at CALL time, like the cards -- call it per eye. +-- Returns the model matrix for BattleBillboard's unit card (x -0.5..0.5, +-- y 0..1 up, v flipped), or nil where the anchors are degenerate. +function BattleScene.fxCard(arena, groundY, anchors) + local p, e = anchors.player, anchors.enemy + local dgb = e[1] - p[1] + if math.abs(dgb) < 1 then return nil end + local GW, GH = BattleScene.GB_W, BattleScene.GB_H + local Px, Py, Pz = arena.player[1], groundY, arena.player[2] + local Ex, Ey, Ez = arena.enemy[1], groundY, arena.enemy[2] + local s = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC + local Mx, My, Mz = (Px + Ex) / 2, groundY, (Pz + Ez) / 2 + + local eye = Voxel3D.eye + local yaw = BattleBillboard.yawToward(Mx, Mz, eye) + local nx, nz = math.sin(yaw), math.cos(yaw) -- out of the frame, at the eye + local rx, rz = math.cos(yaw), -math.sin(yaw) -- the frame's own right + + -- where a world point sits ON the billboard, as (right, up) coordinates + -- about the midpoint: slid along the eye's ray onto the plane, so the + -- mark and the mon line up from exactly the seat that is looking + local function inPlane(qx_, qy_, qz_) + if eye then + local dqx, dqy, dqz = qx_ - eye[1], qy_ - eye[2], qz_ - eye[3] + local denom = dqx * nx + dqz * nz + if math.abs(denom) > 1e-6 then + local t = ((Mx - eye[1]) * nx + (Mz - eye[3]) * nz) / denom + qx_ = eye[1] + dqx * t + qy_ = eye[2] + dqy * t + qz_ = eye[3] + dqz * t + end + end + return (qx_ - Mx) * rx + (qz_ - Mz) * rz, qy_ - My + end + local pax, pay = inPlane(Px, Py, Pz) + local eax, eay = inPlane(Ex, Ey, Ez) + + if math.abs(eax - pax) < 4 then + -- edge-on: the fixed plane through both cells, world-axis mapping + local ux = (Ex - Px) / dgb + local uy = (Ey - Py - s * (p[2] - e[2])) / dgb + local uz = (Ez - Pz) / dgb + local cx = Px + ux * (0.5 * GW - p[1]) + local cy = Py + uy * (0.5 * GW - p[1]) + s * (p[2] - GH) + local cz = Pz + uz * (0.5 * GW - p[1]) + local nl = math.sqrt(ux * ux + uz * uz) + local fx, fz = 0, 1 + if nl > 1e-9 then fx, fz = uz / nl, -ux / nl end + return { ux * GW, 0, fx, cx, + uy * GW, s * GH, 0, cy, + uz * GW, 0, fz, cz, + 0, 0, 0, 1 } + end + + -- in-plane travel per GB pixel of frame x, solved so both marks land: + -- inPlane(gb) = (pax, pay) + U * (gbx - p.x) + (0, s) * (p.y - gby) + local ux = (eax - pax) / dgb + local uy = (eay - pay - s * (p[2] - e[2])) / dgb + local cxp = pax + ux * (0.5 * GW - p[1]) + local cyp = pay + uy * (0.5 * GW - p[1]) + s * (p[2] - GH) + return { rx * ux * GW, 0, nx, Mx + rx * cxp, + uy * GW, s * GH, 0, My + cyp, + rz * ux * GW, 0, nz, Mz + rz * cxp, + 0, 0, 0, 1 } +end + -- The sun has to see the mons too, or they stand on the ground without -- putting anything on it. They are the one thing in this scene that MOVES, -- so `token` -- a counter the caller bumps whenever a pic could have changed diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 8492384..50eb08c 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -68,7 +68,18 @@ OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL, { true, false }, { "ON", "OFF" }) +-- Whether the VR row is ON -- read lazily, because VR requires modules +-- that sit above this one. While it is, this mode stops being optional: +-- the headset's battle seat, the pokedex screen and the effects plane +-- all assume a fight standing on the world, and a white-field battle +-- inside a headset is exactly the flat screen VR exists to replace. +local function vrOn() + local ok, vr = pcall(V.require, "VR") + return ok and vr and vr.enabled and vr.enabled() or false +end + function OverworldBattle.enabled() + if vrOn() then return true end return OverworldBattle.setting:get() and true or false end @@ -98,9 +109,12 @@ OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY, -- Gated on 3D-BTL rather than read alone: with staged battles off there is no -- staged shot for a back pic to be pinned in FRONT of, and the engine's own --- battle screen already draws exactly this. +-- battle screen already draws exactly this. And held OFF under VR: the +-- headset stands both mons on the world -- a flat back pic pinned to the +-- 2D frame would keep your own mon off the arena the battle seat looks at. function OverworldBattle.backPinned() if not OverworldBattle.enabled() then return false end + if vrOn() then return false end return OverworldBattle.backSetting:get() and true or false end @@ -478,6 +492,18 @@ function OverworldBattle.update(dt) -- because rendering them binds canvases, which the eye pass -- mid-scene -- when it wants them -- must never do; reading a stashed canvas is free. session.textures = textures + -- and the move-animation layer, for the same eyes -- rendered only + -- while a headset is actually watching, because only the VR world + -- pass draws it (the flat screen has the animations in-frame already) + session.animTex = nil + local okVR, vrOn = pcall(function() + local vr = V.require("VR") + return vr.active and vr.active() or false + end) + if okVR and vrOn and session.battle then + local okA, anim = pcall(OverworldBattle.animTexture, session.battle) + if okA then session.animTex = anim end + end session.token = (session.token or 0) + 1 local ok, shot = pcall(BattleScene.render, session.state, session.arena, textures, session.token) @@ -553,6 +579,87 @@ function OverworldBattle.worldCards() return BattleScene.monCards(session.arena, groundY, tex), tex, session.token end +-- The live session's BATTLE STATE, once the pushed battle has been met +-- (session.battle fills in from the stack in update). The VR quad reads +-- it to tell "the battle screen is on top" from "a menu is over the +-- battle" -- the UI-only panel is right for the first and wrong for the +-- second. nil with no session, a broken one, or a battle not yet pushed. +function OverworldBattle.battle() + if not (session and not session.broken) then return nil end + return session.battle +end + +-- The move-animation layer as a texture: the engine's own drawAnimLayer, +-- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized +-- transparent canvas of its own. This is what stands the effects up in +-- the VR eyes' world -- see worldAnim below -- the same move the pics +-- made through sideTexture: let the engine draw what it always draws, +-- catch it on a canvas, stand the canvas in the scene. +local animLayer = nil +-- the engine's own drawAnimLayer, captured by install(). Declared HERE, +-- above the function that reads it: a local declared further down the +-- chunk would leave this function reading a global of the same name -- +-- nil forever, and the effects silently absent from the eyes (the bug +-- this comment is the tombstone of). +local innerAnim = nil + +function OverworldBattle.animTexture(battle) + if not (innerAnim and battle) then return nil end + if not (love.graphics and love.graphics.newCanvas) then return nil end + if not animLayer then + local ok, c = pcall(love.graphics.newCanvas, + BattleScene.GB_W, BattleScene.GB_H) + if not (ok and c) then return nil end + pcall(c.setFilter, c, "nearest", "nearest") + animLayer = c + end + local g = love.graphics + local prevCanvas = g.getCanvas() + local ok = pcall(function() + g.push("all") + g.origin() + g.setCanvas(animLayer) + g.clear(0, 0, 0, 0) + g.setBlendMode("alpha") + g.setColor(1, 1, 1, 1) + innerAnim(battle, false) + g.pop() + end) + if not ok then pcall(g.pop, g) end + if prevCanvas then pcall(g.setCanvas, g, prevCanvas) + else pcall(g.setCanvas, g) end + return ok and animLayer or nil +end + +-- The staged fight's effects, for the VR eyes: the animation layer plus +-- the plane to stand it on (BattleScene.fxCard -- anchored so a hit +-- authored at a slot lands on the mon standing in for that slot). nil +-- while nothing is staged or no layer was rendered this frame. +function OverworldBattle.worldAnim() + if not (session and session.arena and not session.broken) then return nil end + local tex = session.animTex + if not tex then return nil end + local host = (session.state and session.state.map) or nil + if not host then return nil end + local groundY = BattleScene.groundY(host, session.arena) + local model = BattleScene.fxCard(session.arena, groundY, + OverworldBattle.ANCHOR) + if not model then return nil end + return tex, model +end + +-- Where the staged fight STANDS -- the arena and its floor height -- for a +-- camera that wants to look at it rather than draw it (the VR battle +-- mount). Answered as soon as the stage exists, textures or not: the +-- camera should be seated behind the fade before the first pic lands. +-- nil whenever no fight is staged on the world. +function OverworldBattle.stage() + if not (session and session.arena and not session.broken) then return nil end + local host = (session.state and session.state.map) or nil + if not host then return nil end + return session.arena, BattleScene.groundY(host, session.arena) +end + function OverworldBattle.invalidate() BattleDOF.invalidate() BattleHud.invalidate() @@ -715,6 +822,8 @@ local texturing = nil local texCanvas = {} local innerPics = nil -- captured by install() local innerHUDs = nil -- likewise, for the snapped HUD layer +-- (innerAnim, their sibling, is declared up beside animTexture, which +-- sits earlier in the chunk than this group and must see the local) local function texCanvasFor(side) local c = texCanvas[side] @@ -993,7 +1102,7 @@ function OverworldBattle.install() -- give them. They ride the average, which is where the pair's centre went -- -- a few pixels at most, and it keeps a hit landing on the mon it is -- aimed at instead of drifting off it. - local innerAnim = BattleState.drawAnimLayer + innerAnim = BattleState.drawAnimLayer function BattleState:drawAnimLayer(colorized) local shot = self.dramaticShapeShot if not shot then return innerAnim(self, colorized) end @@ -1142,6 +1251,13 @@ function OverworldBattle.snapHUDs(battle, shot) if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then return false end + -- With a headset live the HUDs stay IN the GB frame -- the classic + -- slots, on the glass drawHudPanels lays for the unsnapped path. Both + -- of VR's battle screens (the floating panel and the pokedex's) crop + -- to the letterbox, and a block snapped out to the window's edge would + -- be cropped away with the window around it. + local okV, vr = pcall(V.require, "VR") + if okV and vr and vr.active and vr.active() then return false end local slide = (battle.introSlide or 0) * 4 local rects, bandX = OverworldBattle.snapRects(shot) local enemy, player = OverworldBattle.hudLive(battle, slide) diff --git a/lib/Sky.lua b/lib/Sky.lua index 79431a4..545996f 100644 --- a/lib/Sky.lua +++ b/lib/Sky.lua @@ -78,6 +78,19 @@ Sky.DITHER_START = 0.6 -- ladder instead of changing character rung by rung. Sky.SPAN = 0.23 +-- How much ELEVATION the gradient spans above the horizon, in radians, for +-- a caller that anchors the sky IN SPACE rather than to the frame (the VR +-- eyes -- see Voxel3D.beginScene). On the flat screen the bands run from +-- the top edge of the frame down to the horizon, which is right for a +-- camera whose pitch is the rung's: the frame IS the window on the sky. +-- A headset's frame is wherever the head points, so glueing the zenith +-- band to its top edge drags the whole gradient around with the head. An +-- anchored caller instead hangs the gradient over a fixed slice of sky -- +-- horizon to ELEV_SPAN up -- and hands paint() the canvas row that span's +-- top lands on this frame (the `top` argument), so tilting the head slides +-- the frame across a sky that stays put. +Sky.ELEV_SPAN = math.rad(55) + -- ------- the bands -- -- Top first, each a { r, g, b } in 0..1, as the display mode has them. @@ -168,8 +181,15 @@ local SHADER_SRC = [[ uniform Image ramp; // the bands, one texel each, top of the sky first uniform float count; // how many texels wide that ramp is uniform float edge; // the sky's bottom, in canvas pixels +uniform float top; // where the deepest band begins, in canvas pixels -- + // 0 glues the gradient to the frame (the flat + // screen); an anchored caller passes the row its + // fixed elevation span starts on, often negative uniform float cell; // the diorama's pixel size, in canvas pixels uniform float start; // where the checker begins inside a band +uniform float axisX; // the "toward the ground" direction on the canvas: +uniform float axisY; // (0,1) for a level camera; a rolled VR eye tips + // it, and edge/top are distances along it uniform float alpha; uniform float glowAmt; // twilight warmth around the low sun; 0 = none uniform vec2 glowPos; // the sun disc, in canvas pixels @@ -186,8 +206,10 @@ vec3 bandAt(float i) { } vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { - float row = floor(sc.y / cell) * cell; // top of this cell row - float pos = min(row / max(edge, 1.0), 1.0) * count; + vec2 cc0 = floor(sc / cell) * cell; // top of this cell + float row = cc0.x * axisX + cc0.y * axisY; // along the axis + if (row > edge) { discard; } // below the horizon + float pos = clamp((row - top) / max(edge - top, 1.0), 0.0, 1.0) * count; float base = min(floor(pos), count - 1.0); vec3 c = bandAt(base); float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0); @@ -310,13 +332,14 @@ Sky._getShader = getShader -- named for the suite -- The flat fallback: the same bands as solid rectangles, no checker, on the same -- quantised edges. For a driver that could not compile the shader -- which is -- also every headless run. -local function paintFlat(w, h, bands, edge, alpha, cell) +local function paintFlat(w, h, bands, edge, alpha, cell, top) local g = love.graphics local n = #bands + local span = edge - (top or 0) local prev = 0 for i = 1, n do local cut = (i == n) and math.min(h, math.ceil(edge)) - or math.floor(i / n * edge / cell + 0.5) * cell + or math.floor(((top or 0) + i / n * span) / cell + 0.5) * cell cut = math.max(prev, math.min(cut, math.min(h, math.ceil(edge)))) if cut > prev then local c = bands[i] @@ -383,7 +406,7 @@ function Sky.discRadius(h, cell, body) return r * cell, r end -local function paintDisc(body, edge, cell, w, h) +local function paintDisc(body, edge, cell, w, h, axis) local g = love.graphics if not (body and body.y and g.setScissor) then return end local shades = Sky.discShades(body.moon) @@ -392,12 +415,31 @@ local function paintDisc(body, edge, cell, w, h) -- snap the centre to the cell grid, like everything else in this sky local bx = math.floor(body.x / cell) * cell + cell / 2 local by = math.floor(body.y / cell) * cell + cell / 2 - if by - r * cell > edge then return end -- wholly below the horizon point + -- wholly below the horizon point -- measured along the tilt axis when + -- the caller has one (a rolled VR eye) + local bt = axis and (bx * axis[1] + by * axis[2]) or by + if bt - r * cell > edge then return end local core = shades[1] local main = shades[twilight and 3 or 2] local sx, sy, sw, sh = g.getScissor() - g.setScissor(0, 0, math.ceil(w), math.floor(edge)) + -- the scissor is axis-aligned and cannot follow a tilted horizon; under + -- an axis the world drawn after this covers the ground side anyway, so + -- the region opens to the frame and only the cull above clips the disc + if axis then + g.setScissor(0, 0, math.ceil(w), math.ceil(h)) + else + g.setScissor(0, 0, math.ceil(w), math.floor(edge)) + end local craterR = math.max(1, math.floor(r / 5)) + -- The disc's cells are drawn in the HORIZON'S frame, not the canvas's: + -- under an axis (a VR eye that can roll) the whole pattern -- craters, + -- rim dither, the cell grid itself -- is rotated to stay upright over + -- the world, or a tipped head watches the moon's face spin in place. + -- Level cameras rotate by zero and draw exactly what they always drew. + local rot = axis and math.atan2(-axis[1], axis[2]) or 0 + g.push() + g.translate(bx, by) + if rot ~= 0 then g.rotate(rot) end for dy = -r, r do for dx = -r, r do local d = math.sqrt(dx * dx + dy * dy) @@ -416,12 +458,13 @@ local function paintDisc(body, edge, cell, w, h) end if keep then g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1) - g.rectangle("fill", bx + dx * cell - cell / 2, - by + dy * cell - cell / 2, cell, cell) + g.rectangle("fill", dx * cell - cell / 2, + dy * cell - cell / 2, cell, cell) end end end end + g.pop() if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end g.setColor(1, 1, 1, 1) end @@ -436,16 +479,29 @@ end -- the caller's own camera (Voxel3D.skyBody), with the twilight glow riding -- along; nil hangs nothing and warms nothing. -- +-- `top` anchors the gradient in space rather than to the frame: the canvas +-- row band 1 starts on (often negative -- above the frame), from a caller +-- that mapped a fixed elevation span to its own camera (see ELEV_SPAN). +-- nil or 0 is the flat screen's behaviour: zenith band at the top edge. +-- +-- `axis` tips the whole painting to a rolled camera's true horizon: a unit +-- {ax, ay} pointing "toward the ground" on the canvas (Voxel3D.horizonLine), +-- with `horizonY` and `top` then read as distances ALONG it rather than as +-- rows. nil is the level default. Only the shader path can tilt; the flat +-- fallback paints level, which only a headless run ever sees. +-- -- Returns false when there is nothing to paint, in which case the caller's flat -- fill is the whole sky. That fill is the palest band, so a frame that declines -- this looks like a hazy day rather than like a bug. -function Sky.paint(w, h, sky, horizonY, cell, body) +function Sky.paint(w, h, sky, horizonY, cell, body, top, axis) local bands = sky and sky.bands if not (bands and bands[1]) then return false end if not (w and h and w > 0 and h > 0) then return false end local g = love.graphics if not (g and g.rectangle) then return false end - local edge = Sky.region(h, horizonY) + -- along an axis the caller's edge is already the signed distance and + -- has no row to be clamped to; level callers keep the SPAN fallback + local edge = axis and horizonY or Sky.region(h, horizonY) if not edge then return false end local alpha = sky[4] or 1 cell = math.max(1, math.floor((cell or 1) + 0.5)) @@ -474,6 +530,9 @@ function Sky.paint(w, h, sky, horizonY, cell, body) sh:send("ramp", ramp) sh:send("count", #bands) sh:send("edge", edge) + sh:send("top", math.min(top or 0, edge - 1)) + sh:send("axisX", axis and axis[1] or 0) + sh:send("axisY", axis and axis[2] or 1) sh:send("cell", cell) sh:send("start", Sky.DITHER and Sky.DITHER_START or 2) sh:send("alpha", alpha) @@ -488,16 +547,22 @@ function Sky.paint(w, h, sky, horizonY, cell, body) if sent then g.setShader(sh) g.setColor(1, 1, 1, 1) - g.rectangle("fill", 0, 0, w, math.min(h, math.ceil(edge))) + -- tilted, the sky's reach is not a row: the full frame goes through + -- the shader and the discard is the boundary + local rectH = axis and h or math.min(h, math.ceil(edge)) + g.rectangle("fill", 0, 0, w, rectH) g.setShader() else sh = nil end end - if not sh then paintFlat(w, h, bands, edge, alpha, cell) end + if not sh then + paintFlat(w, h, bands, axis and math.min(h, edge) or edge, alpha, cell, + math.min(top or 0, edge - 1)) + end -- the disc goes over the glow, under nothing: plain rectangles, so it is -- there whether or not the shader built - paintDisc(body, math.min(h, edge), cell, w, h) + paintDisc(body, axis and edge or math.min(h, edge), cell, w, h, axis) g.setColor(1, 1, 1, 1) if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end diff --git a/lib/VR.lua b/lib/VR.lua index 4bb81f8..3a261a3 100644 --- a/lib/VR.lua +++ b/lib/VR.lua @@ -24,10 +24,16 @@ -- 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. 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 -- --- v1 has no XR controller bindings on purpose. +-- 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 @@ -45,6 +51,7 @@ 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 = {} @@ -52,7 +59,11 @@ local VR = {} -- 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 +-- 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 } @@ -74,12 +85,58 @@ local lastOrbit = 4 -- the 50-degree rung, a sane middle 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.setting:get() == true + return VR.supported() and VR.setting:get() == true end function VR.active() @@ -124,15 +181,68 @@ local function shutdown(reason) 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) +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() @@ -142,19 +252,39 @@ local function renderWorld(views) local vw, vh = 320, 288 pcall(function() vw, vh = Game.renderer:worldViewSize() end) - local pivot, anchor, scale + -- 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() - if fp then + 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 HMD is the head: its yaw and pitch become FirstPerson's, so - -- FreeMove walks where you look and A talks to what you face + -- 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 = yaw + FirstPerson.yaw = wrapPi(yaw + fpYawOff) FirstPerson.pitch = math.max(FirstPerson.PITCH_UP, math.min(FirstPerson.PITCH_DOWN, pitch)) else @@ -168,14 +298,37 @@ local function renderWorld(views) 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), + camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw), w = v.w, h = v.h, slot = i == 1 and "vrL" or "vrR", - adopt = true, + -- 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] @@ -187,6 +340,21 @@ local function renderWorld(views) 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) @@ -214,24 +382,44 @@ end -- world pass is off entirely. local function wantQuad(worldUp) if not worldUp then return true end - 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 + 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) VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh)) VRXR.releaseQuad() - return fp and QUAD_FP or QUAD_DIORAMA + -- 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), so the quad's sub-rect crops to + -- it and the panel presents near-square instead of monitor-wide. + -- Image space is GL's, origin bottom-left -- exactly how + -- copyFrontBuffer landed the window in the texture. + local crop = nil + pcall(function() + local BattleScene = V.require("BattleScene") + local lx, ly, s = BattleScene.letterbox() + local wpx = BattleScene.GB_W * s + local hpx = BattleScene.GB_H * s + local x = math.max(0, math.floor(lx)) + local y = math.max(0, math.floor(wh - ly - hpx)) + crop = { x, y, + math.min(qw - x, math.ceil(wpx)), + math.min(qh - y, math.ceil(hpx)) } + if crop[3] < 1 or crop[4] < 1 then crop = nil end + end) + 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 @@ -242,10 +430,13 @@ end -- so it grid-walks the diorama and free-walks 1ST); -- A/B are A/B; either trigger is START; clicking the -- LEFT stick toggles first/third person. --- diorama only right stick up/down zooms the model; clicking the --- RIGHT stick cycles the viewing angle through the orbit --- rungs; squeezing a grip and moving that hand up or --- down drags the whole table with it. +-- 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. -- Flip between the 1ST rung and the last orbit rung, through the same -- gate and plumbing the keyboard hotkey uses. @@ -269,33 +460,15 @@ function VR.toggleView() end) end --- Step the diorama's presentation angle through the orbit rungs (the --- anchor re-derives from the rung's angle, so the table re-tilts on the --- rung's own tween). FULL counts as its 35-degree twin, like the hotkey. -function VR.cycleAngle() +-- 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") - local Pipelines = require("src.render.Pipelines") - local top = Game.stack and Game.stack:top() - if not Pipelines.canToggle("voxel", top, Game.overworld) then return end - local level = Pipelines.level("voxel") - if Voxel.isFirstPerson(level) then return end - local order = { 2, 3, 4, 5 } - local at = nil - for i, rung in ipairs(order) do - if rung == level then at = i break end - end - if not at then - local deg = Voxel.ANGLES_DEG[level + 1] - for i, rung in ipairs(order) do - if Voxel.ANGLES_DEG[rung + 1] == deg then at = i break end - end - end - Pipelines.setLevel("voxel", order[at and (at % #order + 1) or 1]) - if Game.save and Game.save.options then - Pipelines.syncOptions(Game.save.options) - pcall(Game.writeOptions, Game) - end + VR.setting:setIndex(VR.setting:read() + 1, Game) end) end @@ -331,12 +504,28 @@ local function driveControls(ctl, dt, fp) if ctl.toggleChanged and ctl.toggle then VR.toggleView() end - if not fp then + -- 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 - if ctl.angleChanged and ctl.angle then VR.cycleAngle() 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 @@ -406,18 +595,33 @@ function VR.update(dt) -- 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 - driveControls(VRXR.input(time), dt, FirstPerson.engaged()) + -- 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) + worldUp = renderWorld(views, ctl) end end local quadPose = updateQuad(worldUp, FirstPerson.engaged()) @@ -458,6 +662,9 @@ function VR.invalidate() 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 diff --git a/lib/VRGL.lua b/lib/VRGL.lua index 1522fc4..bf9245e 100644 --- a/lib/VRGL.lua +++ b/lib/VRGL.lua @@ -166,6 +166,32 @@ function VRGL.copyFrontBuffer(tex, w, h) return ok end +-- Copy the window's front buffer into a LOVE CANVAS (by its FBO id, from +-- canvasFBO), flipped so the canvas reads top-down exactly like the +-- window: what LOVE then draws from that canvas at (0,0) is the screen, +-- row for row. The battle's VR quad is the caller: it needs the screen as +-- something LOVE can CUT UP (scissored cutouts of the UI), not just as a +-- finished texture -- copyFrontBuffer above is for the finished case. +function VRGL.copyFrontToCanvas(dstFBO, w, h) + if not ready then return false end + local ok = pcall(function() + ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, 0) + gl.glReadBuffer(GL.FRONT) + ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, dstFBO) + ext.glBlitFramebuffer(0, 0, w, h, 0, h, w, 0, + GL.COLOR_BUFFER_BIT, GL.NEAREST) + ext.glBindFramebuffer(GL.FRAMEBUFFER, 0) + gl.glReadBuffer(GL.BACK) + end) + if not ok then + pcall(function() + ext.glBindFramebuffer(GL.FRAMEBUFFER, 0) + gl.glReadBuffer(GL.BACK) + end) + end + return ok +end + function VRGL.status() if ready then return "ok" end return reason or "not loaded" diff --git a/lib/VRRig.lua b/lib/VRRig.lua index aa89c48..1b01d1b 100644 --- a/lib/VRRig.lua +++ b/lib/VRRig.lua @@ -85,6 +85,38 @@ end -- an angle nobody supplied VRRig.TABLE = { 0, -0.45, -0.75 } +-- ------- the battle mount +-- +-- A staged fight snaps the headset to an OVER-THE-SHOULDER seat: the same +-- line the flat battle camera stands on (eye through focus, so the player's +-- mon is near-left and the foe far-right exactly as the flat shot frames +-- them), but pulled in to BATTLE_DIST -- the flat rig is a long lens from +-- fifteen metres back, and a headset's lens is its own eyes, so keeping the +-- distance would shrink the fight to a stage seen from the back row. 66 px +-- is the wide rig's own standing distance: six and a half metres at life +-- scale, close enough to fill the view, far enough to hold both mons in it +-- -- and short enough to stay inside the small rooms the wide rig exists +-- for. +VRRig.BATTLE_DIST = 66 + +-- Where the head sits for a staged fight, and which way the mapping must +-- turn so that seat FACES it. Returns the pivot (world px -- pin the XR +-- origin here at FP_SCALE) and the yaw for eyeCamera: the flat camera +-- looks along focus - eye, the resting headset looks along XR -Z (world +-- north), and the yaw is what closes that gap. +function VRRig.battleMount(eye, focus) + local dx = eye[1] - focus[1] + local dy = eye[2] - focus[2] + local dz = eye[3] - focus[3] + local len = math.sqrt(dx * dx + dy * dy + dz * dz) + if len < 1e-6 then return { eye[1], eye[2], eye[3] }, 0 end + local k = VRRig.BATTLE_DIST / len + -- Ry(yaw) sends XR forward (0,0,-1) to (-sin yaw, 0, -cos yaw); aiming + -- that along the horizontal of focus - eye solves to atan2 of eye - focus + return { focus[1] + dx * k, focus[2] + dy * k, focus[3] + dz * k }, + math.atan2(dx, dz) +end + -- eye-space clip planes, in metres (see the unit note above) VRRig.NEAR = 0.05 VRRig.FAR = 400 @@ -98,19 +130,25 @@ VRRig.FAR = 400 -- pivot {x,y,z} world px pinned to `anchor` -- anchor {x,y,z} LOCAL metres (VRRig.TABLE, or 0,0,0 for first person) -- scale world px per metre +-- yaw optional turn of the whole mapping about +Y, radians: the +-- battle mount faces the resting head at the arena with it. +-- worldFromXr(p) becomes pivot + s * Ry(yaw) * (p - anchor). -- -- Returns a table shaped for Voxel3D.camera: raw view + proj, the world -- eye and focus (for setLook, the water's lean, the sky), fov as a -- vertical span, and the curve declined -- a bent tabletop reads as a -- broken model, and first person already declines it on the flat screen. -function VRRig.eyeCamera(pose, fov, pivot, anchor, scale) +function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw) local px, py, pz = pose.pos[1], pose.pos[2], pose.pos[3] local q = pose.quat local R = Mat4.fromQuat(q[1], q[2], q[3], q[4]) - -- view = R^T * T(-pos) * T(anchor) * S(1/s) * T(-pivot) + -- view = R^T * T(-pos) * T(anchor) * Ry(-yaw) * S(1/s) * T(-pivot) local view = Mat4.mul(Mat4.transpose(R), Mat4.translate(-px, -py, -pz)) view = Mat4.mul(view, Mat4.translate(anchor[1], anchor[2], anchor[3])) + if yaw and yaw ~= 0 then + view = Mat4.mul(view, Mat4.rotateY(-yaw)) + end view = Mat4.mul(view, Mat4.scale(1 / scale, 1 / scale, 1 / scale)) view = Mat4.mul(view, Mat4.translate(-pivot[1], -pivot[2], -pivot[3])) @@ -120,11 +158,17 @@ function VRRig.eyeCamera(pose, fov, pivot, anchor, scale) -- the eye and its forward, in world pixels: worldFromEye applied to the -- origin and to -Z - local ex = pivot[1] + scale * (px - anchor[1]) - local ey = pivot[2] + scale * (py - anchor[2]) - local ez = pivot[3] + scale * (pz - anchor[3]) + local ax, ay, az = px - anchor[1], py - anchor[2], pz - anchor[3] -- R's third column is the eye's +Z axis; forward is its negation local fx, fy, fz = -R[3], -R[7], -R[11] + if yaw and yaw ~= 0 then + local c, s = math.cos(yaw), math.sin(yaw) + ax, az = c * ax + s * az, -s * ax + c * az + fx, fz = c * fx + s * fz, -s * fx + c * fz + end + local ex = pivot[1] + scale * ax + local ey = pivot[2] + scale * ay + local ez = pivot[3] + scale * az return { view = view, @@ -136,6 +180,24 @@ function VRRig.eyeCamera(pose, fov, pivot, anchor, scale) } end +-- The WORLD model matrix a hand-held prop stands on: worldFromXr (the +-- same mapping the eyes use -- so the prop is exactly where the hand is, +-- whatever mode the mapping is in) composed with the hand's own tracked +-- pose. A mesh authored in METRES rides it straight: the mapping's scale +-- is what turns metres into world pixels, so the prop keeps its real +-- size in the hand at the diorama's scale and at life scale alike. +-- +-- model = T(pivot) * S(s) * Ry(yaw) * T(-anchor) * T(hand.pos) * R(hand.quat) +function VRRig.propMatrix(pose, pivot, anchor, scale, yaw) + local m = Mat4.translate(pivot[1], pivot[2], pivot[3]) + m = Mat4.mul(m, Mat4.scale(scale, scale, scale)) + if yaw and yaw ~= 0 then m = Mat4.mul(m, Mat4.rotateY(yaw)) end + m = Mat4.mul(m, Mat4.translate(-anchor[1], -anchor[2], -anchor[3])) + m = Mat4.mul(m, Mat4.translate(pose.pos[1], pose.pos[2], pose.pos[3])) + local q = pose.quat + return Mat4.mul(m, Mat4.fromQuat(q[1], q[2], q[3], q[4])) +end + -- The flat compass numbers a head orientation implies, for driving -- FirstPerson (and through it FreeMove) from the HMD: yaw in this mod's -- convention (0 south, pi/2 east) and pitch positive-down. diff --git a/lib/VRXR.lua b/lib/VRXR.lua index dae1703..7e04154 100644 --- a/lib/VRXR.lua +++ b/lib/VRXR.lua @@ -477,8 +477,6 @@ local function setupInput() start = makeAction(set, "btn_start", XR.ACTION_TYPE_BOOLEAN, "Start"), toggle = makeAction(set, "viewtoggle", XR.ACTION_TYPE_BOOLEAN, "First / Third Person"), - angle = makeAction(set, "angle", XR.ACTION_TYPE_BOOLEAN, - "Diorama Angle"), gripl = makeAction(set, "grip_l", XR.ACTION_TYPE_FLOAT, "Left Grip"), gripr = makeAction(set, "grip_r", XR.ACTION_TYPE_FLOAT, "Right Grip"), handl = makeAction(set, "hand_l", XR.ACTION_TYPE_POSE, "Left Hand"), @@ -511,7 +509,6 @@ local function setupInput() { A.start, "/user/hand/left/input/trigger/value" }, { A.start, "/user/hand/right/input/trigger/value" }, { A.toggle, "/user/hand/left/input/thumbstick/click" }, - { A.angle, "/user/hand/right/input/thumbstick/click" }, { A.gripl, "/user/hand/left/input/squeeze/value" }, { A.gripr, "/user/hand/right/input/squeeze/value" }, { A.handl, "/user/hand/left/input/grip/pose" }, @@ -534,7 +531,6 @@ local function setupInput() { A.start, "/user/hand/left/input/trigger/value" }, { A.start, "/user/hand/right/input/trigger/value" }, { A.toggle, "/user/hand/left/input/thumbstick/click" }, - { A.angle, "/user/hand/right/input/thumbstick/click" }, { A.gripl, "/user/hand/left/input/squeeze/click" }, { A.gripr, "/user/hand/right/input/squeeze/click" }, { A.handl, "/user/hand/left/input/grip/pose" }, @@ -617,7 +613,6 @@ function VRXR.input(time) o.b, o.bChanged = readBool(A.b) o.start, o.startChanged = readBool(A.start) o.toggle, o.toggleChanged = readBool(A.toggle) - o.angle, o.angleChanged = readBool(A.angle) o.gripL = readFloat(A.gripl) o.gripR = readFloat(A.gripr) @@ -632,6 +627,13 @@ function VRXR.input(time) local fl = tonumber(loc.locationFlags) or 0 if fl % 4 >= XR.SPACE_LOCATION_POSITION_VALID_BIT then o[name .. "Y"] = loc.pose.position.y + -- and the whole pose when the orientation is valid too + -- (bit 0x1): the hand-held pokedex stands on it + if fl % 2 >= 1 then + local p, q = loc.pose.position, loc.pose.orientation + o[name] = { pos = { p.x, p.y, p.z }, + quat = { q.x, q.y, q.z, q.w } } + end end end end @@ -912,7 +914,12 @@ end -- Close the frame. `world` submits the projection layer from the LAST -- locateViews (both eyes assumed blitted); `quadOpts` floats the UI panel: --- { pos = {x,y,z}, quat = {x,y,z,w}, width = metres }. Either may be nil. +-- { pos = {x,y,z}, quat = {x,y,z,w}, width = metres, +-- crop = {x, y, w, h} or nil }. Either may be nil. `crop` shows only +-- that sub-rect of the quad's texture -- GL image coordinates, origin +-- bottom-left, exactly as the front-buffer copy landed the window -- and +-- the panel's aspect follows the rect (the GB letterbox's near-square +-- frame) instead of the whole texture's. function VRXR.endFrame(time, world, quadOpts) if not running then return end pcall(function() @@ -946,8 +953,16 @@ function VRXR.endFrame(time, world, quadOpts) quadLayer[0].space = space quadLayer[0].eyeVisibility = XR.EYE_VISIBILITY_BOTH quadLayer[0].subImage.swapchain = quad.swapchain - quadLayer[0].subImage.imageRect.extent.width = quad.w - quadLayer[0].subImage.imageRect.extent.height = quad.h + local cr = quadOpts.crop + if cr then + quadLayer[0].subImage.imageRect.offset.x = cr[1] + quadLayer[0].subImage.imageRect.offset.y = cr[2] + quadLayer[0].subImage.imageRect.extent.width = cr[3] + quadLayer[0].subImage.imageRect.extent.height = cr[4] + else + quadLayer[0].subImage.imageRect.extent.width = quad.w + quadLayer[0].subImage.imageRect.extent.height = quad.h + end local p, q = quadOpts.pos or { 0, 0, -1 }, quadOpts.quat or { 0, 0, 0, 1 } quadLayer[0].pose.position.x = p[1] quadLayer[0].pose.position.y = p[2] @@ -958,7 +973,11 @@ function VRXR.endFrame(time, world, quadOpts) quadLayer[0].pose.orientation.w = q[4] local w = quadOpts.width or 1.0 quadLayer[0].size.width = w - quadLayer[0].size.height = w * (quad.h / quad.w) + if cr then + quadLayer[0].size.height = w * (cr[4] / math.max(1, cr[3])) + else + quadLayer[0].size.height = w * (quad.h / quad.w) + end layers[#layers + 1] = quadLayer end diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 0b0f73a..aa3cb58 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -585,6 +585,56 @@ function Voxel3D.horizonY(h) return (y / w * 0.5 + 0.5) * h end +-- The horizon as a LINE rather than a row, for a camera that can ROLL -- +-- a VR eye. A head tipped sideways tips the true horizon across the +-- canvas, and a sky painted in flat rows then visibly hinges with the +-- head. So: project the flat forward direction (a point ON the vanishing +-- line) and the same direction nudged a hair of world-up (a point just +-- above it); the difference is the canvas direction "down toward the +-- ground", perpendicular to the horizon however the head is tipped. +-- +-- Returns (ax, ay, edge, top): a unit axis in canvas pixels pointing from +-- sky toward ground, the horizon's signed distance along it -- a pixel at +-- canvas (x, y) is above the horizon while x*ax + y*ay < edge -- and, +-- when `elev` (radians) is given, the distance the direction that far +-- ABOVE the horizon projects to. `top` is what pins the gradient's far +-- end to a real direction in the sky: extrapolating it linearly from a +-- pixels-per-radian estimate left the bands sliding as a pitch moved the +-- horizon through the frame, because a perspective's rows are tan-spaced, +-- not angle-spaced. nil `top` (the elevated direction is outside this +-- frustum's forward hemisphere) leaves the caller its estimate. nil +-- everything with no horizon in front of this camera. +function Voxel3D.horizonLine(w, h, elev) + local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus + if not (m and eye and focus and w and h and h > 0) then return nil end + local dx = focus[1] - eye[1] + local dz = focus[3] - eye[3] + local len = math.sqrt(dx * dx + dz * dz) + if len < 1e-6 then return nil end + dx, dz = dx / len, dz / len + local function proj(vx, vy, vz) + local x = m[1] * vx + m[2] * vy + m[3] * vz + local y = m[5] * vx + m[6] * vy + m[7] * vz + local ww = m[13] * vx + m[14] * vy + m[15] * vz + if ww <= 1e-6 then return nil end + return (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h + end + local qx, qy = proj(dx, 0, dz) + if not qx then return nil end + local rx, ry = proj(dx, 0.02, dz) + if not rx then return nil end + local ax, ay = qx - rx, qy - ry + local al = math.sqrt(ax * ax + ay * ay) + if al < 1e-6 then ax, ay = 0, 1 else ax, ay = ax / al, ay / al end + local top = nil + if elev then + local ce, se = math.cos(elev), math.sin(elev) + local tx, ty = proj(dx * ce, se, dz * ce) + if tx then top = tx * ax + ty * ay end + end + return ax, ay, qx * ax + qy * ay, top +end + -- ------- the hour's light -- -- What the scene shader multiplies every surface by (see dayTint in the @@ -689,10 +739,38 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- screen. The sky's dither grid is cut to it, and so is the water's -- -- one number, so the two break up on the same checkerboard. Voxel3D.cell = w / math.max(1, vw or w) + -- A VR eye's sky is ANCHORED IN SPACE, where the flat screen's is glued + -- to the frame. The differences all key off the raw-matrix camera only + -- the VR eyes bring: the horizon must really be in frame for any band + -- to paint (no Sky.SPAN fallback -- that slice pinned to the top of the + -- view is exactly "the sky moves with the headset"); the gradient hangs + -- over a fixed ELEVATION span above the horizon rather than filling up + -- to the frame's edge; the whole painting runs along the horizon's OWN + -- AXIS (horizonLine), so a rolled head sees the horizon hold level in + -- the world instead of hinging with the ears; and the sun or moon was + -- already honest -- skyBody projects the hour's direction through this + -- very eye. + local vrEye = Voxel3D.camera and Voxel3D.camera.view and Voxel3D.camera.proj + and true or false + local hy = Voxel3D.horizonY(h) + local ax, ay, edgeT, topT + if vrEye then + ax, ay, edgeT, topT = Voxel3D.horizonLine(w, h, Sky.ELEV_SPAN) + end + -- any sky in frame at all? the corner most toward the sky must sit + -- above the horizon's line + local skyUp = false + if sky and sky.bands then + if not vrEye then + skyUp = true + elseif edgeT then + local minT = math.min(0, w * ax, h * ay, w * ax + h * ay) + skyUp = edgeT > minT + 1 + end + end -- and where the sky's bottom edge lands, which is what the reflection -- reads its bands against (see Water). nil when nothing painted bands. - Voxel3D.skyEdge = (sky and sky.bands) - and Sky.region(h, Voxel3D.horizonY(h)) or nil + Voxel3D.skyEdge = skyUp and Sky.region(h, hy) or nil if sky then love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true) -- The sky goes down here, in the one window in this function where a @@ -705,8 +783,24 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- are the same size as the world's own and follow every resize and zoom. -- The banded sky also hangs the hour's sun or moon (skyBody projects it -- through this very camera); a flat sky has no bands and hangs nothing. - Sky.paint(w, h, sky, Voxel3D.horizonY(h), Voxel3D.cell, - sky.bands and Voxel3D.skyBody(w, h) or nil) + if not vrEye then + Sky.paint(w, h, sky, hy, Voxel3D.cell, + sky.bands and Voxel3D.skyBody(w, h) or nil) + elseif skyUp then + -- the gradient's far end: the ELEV_SPAN direction's own projection + -- when the frustum holds it (horizonLine's `top` -- exact, so a + -- pitch slides the frame over bands that stay put), and the + -- pixels-per-radian estimate when it does not + local top = topT + if not (top and top < edgeT - 1) then + local radPerPx = math.max(1e-6, (Voxel3D.fovY or 1) / h) + top = edgeT - Sky.ELEV_SPAN / radPerPx + end + Sky.paint(w, h, sky, edgeT, Voxel3D.cell, + Voxel3D.skyBody(w, h), top, { ax, ay }) + end + -- a VR eye with no horizon in frame paints nothing: everything in view + -- is below the horizon, and the haze clear above already filled it else love.graphics.clear(0, 0, 0, 0, true, true) end diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index bbd8dff..19179a2 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -26,6 +26,7 @@ local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") local FirstPerson = V.require("FirstPerson") local BattleBillboard = V.require("BattleBillboard") +local Pokedex = V.require("Pokedex") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -262,21 +263,32 @@ end -- carries one pose into the other -- the lean eases out as the yaw eases in -- -- and cardBlend is zero for every camera that is not the first-person -- rig, the battle's placed shot included, so nothing else moves. +-- The pitch the sprite cards lean back by -- normally the rung's own +-- camera angle, overridable in radians. VR sets the override to the top +-- rung's 75 degrees for every diorama and battle frame: a table watched +-- from a freely moving head has no one camera pitch for the cards to +-- match, and the near-upright top-rung lean is the pose that reads as +-- "standing" from anywhere around it. nil (the default, and the flat +-- screen always) leans with the rung as ever. +VoxelScene.spriteLean = nil + +local function leanAngle() + return VoxelScene.spriteLean or V.require("VoxelState").angle +end + local function billboardMatrix(px, py, y, mirror) - local Voxel = V.require("VoxelState") local b = FirstPerson.cardBlend() local m = Mat4.translate(px + 8, y, py + 8) if b > 0 then m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b)) end - m = Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b))) + m = Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b))) if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end return Mat4.mul(m, Mat4.translate(-8, 0, 0)) end local function billboardPull() - local Voxel = V.require("VoxelState") - return VoxelScene.pull(math.max(Voxel.angle, 0.05)) + return VoxelScene.pull(math.max(leanAngle(), 0.05)) end -- An authored FIGURE's card -- a person the tileset draws INTO a piece of @@ -295,7 +307,6 @@ end -- his edge would swing him off his seat. The width rode in on the record -- for exactly this (ChunkMesher.buildFigureMeshes). local function figureMatrix(f, offX, offZ) - local Voxel = V.require("VoxelState") local b = FirstPerson.cardBlend() local wx, wz = f.wx + (offX or 0), f.wz + (offZ or 0) local m = Mat4.translate(wx, f.y, wz) @@ -305,7 +316,7 @@ local function figureMatrix(f, offX, offZ) m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(wx + half, wz) * b)) m = Mat4.mul(m, Mat4.translate(-half, 0, 0)) end - return Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b))) + return Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b))) end -- What the sun sees: the same card UNLEANED and flattened, exactly as @@ -1054,6 +1065,17 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) BattleBillboard.PULL) end if battleTex.flash then Voxel3D.flatten(nil) end + -- and the MOVE ANIMATIONS, standing on the same arena: the + -- engine's own effects layer on the plane through both cells + -- (BattleScene.fxCard), pulled a little harder than the mons so + -- a burst plays over the card it is bursting on + local okA, fxTex, fxModel = pcall(function() + return V.require("OverworldBattle").worldAnim() + end) + if okA and fxTex and fxModel then + Voxel3D.draw(BattleBillboard.mesh(), fxTex, fxModel, + BattleBillboard.PULL + 6) + end Voxel3D.seams(true) Voxel3D.glass(true) end @@ -1064,8 +1086,10 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- is preserved, so the row still overdraws feet -- the 3D version of -- the GB's grass-over-feet trick -- while grass keeps losing to the -- buildings it genuinely stands behind (far deeper than the pull). - local Voxel = V.require("VoxelState") - local pull = VoxelScene.pull(math.max(Voxel.angle, 0.05)) + -- the same angle the cards leaned by (leanAngle honours VR's override), + -- so the tuft rows keep exactly the characters' own depth handicap + local lean = math.max(leanAngle(), 0.05) + local pull = VoxelScene.pull(lean) Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull) for _, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), @@ -1081,7 +1105,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) -- lands behind the card and the player obscures the patch they stand -- ON, while the nearest flower of the cell south (+20) stays in front -- and keeps overdrawing their feet. - local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05))) + local fpull = math.max(0, pull - 8 * math.sin(lean)) -- flowers are snugged casters too, so they read their own shadowing -- through the same snugged transform the sun stored them with Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil, @@ -1092,6 +1116,20 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end + -- The VR pokedex in the player's left hand, last of all: a prop over + -- the world drawn with real depth, so leaning it into a wall still + -- occludes honestly. Its frame only exists while a session is live and + -- the left hand is tracked (VR.lua sets it), so every flat frame skips + -- this in one field read. No wireframe and no glass, like the cast: + -- the device is a drawing riding the scene, not part of the terrain. + if Pokedex.frame then + Voxel3D.glass(false) + Voxel3D.seams(false) + Pokedex.draw() + Voxel3D.seams(true) + Voxel3D.glass(true) + end + end -- drawScene if not eyes then diff --git a/main.lua b/main.lua index cce7f1c..5c52632 100644 --- a/main.lua +++ b/main.lua @@ -395,10 +395,14 @@ local SETTINGS = { .. "fraction of the cost." }, -- `full` marks a row FULL does not take away. FULL owns the diorama's own -- knobs; what a battle is drawn over, and how it is framed, are not that. + -- Off the OPTIONS menu while VR is on: the headset REQUIRES staged + -- battles (OverworldBattle.enabled answers true regardless of this row) + -- and forbids back sprites (backPinned answers false), so both rows + -- decide nothing there and a dead switch on the menu reads as broken. { OverworldBattle.setting, "Fight on the map: the battle draws over the nearest clear ground, " .. "shot over the shoulder with a slow parallax drift.", - full = true }, + when = function() return not VR.enabled() end, full = true }, -- Only offered while a fight can actually be staged on the map: with 3D-BTL -- off the engine draws the classic screen, which is this row's ON already, -- and a row that no longer decides anything is worse than no row. @@ -406,7 +410,8 @@ local SETTINGS = { "Keep your own Pokemon on the battle menu, seen from behind in its " .. "original slot, instead of standing it on the map facing the foe. " .. "The foe is still out there on its own tile.", - when = function() return stagedBattles() end, full = true }, + when = function() return stagedBattles() and not VR.enabled() end, + full = true }, { DayNight.setting, "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " @@ -436,12 +441,20 @@ local SETTINGS = { .. "Menus and dialogs float on a panel. Needs a Windows OpenXR runtime " .. "and the mod running from a real folder; without them the row stays " .. "and the game stays flat, with the reason on the console.", - full = true }, + -- on Windows the row stays even when a runtime is missing (the console + -- says why); off Windows -- mobile above all -- there is no VR to have + -- and the row does not exist + when = function() return VR.supported() end, full = true }, } local schema = {} -for i, entry in ipairs(SETTINGS) do - schema[i] = entry[1]:schema(entry[2]) +for _, entry in ipairs(SETTINGS) do + -- the VR row is absent from the mod manager's page too where the + -- platform cannot do VR at all -- the OPTIONS menu's `when` gates are + -- situational (a row hidden for now), this one is existential + if entry[1] ~= VR.setting or VR.supported() then + schema[#schema + 1] = entry[1]:schema(entry[2]) + end end mod.options:define(schema) @@ -804,12 +817,16 @@ do function OptionsMenu:update(dt) local before = Pipelines.level("voxel") local hadBattles = OverworldBattle.enabled() + -- the VR row hides the two battle rows while it is on, so stepping + -- it changes the LIST exactly the way 3D-BTL does + local hadVR = VR.enabled() local wasOn = idAt(self, self.index) inner(self, dt) local after = Pipelines.level("voxel") local crossedFull = after ~= before and (Voxel.isFull(before) or Voxel.isFull(after)) - if crossedFull or OverworldBattle.enabled() ~= hadBattles then + if crossedFull or OverworldBattle.enabled() ~= hadBattles + or VR.enabled() ~= hadVR then local rebuilt = OptionsMenu.new(self.game) self.rows = rebuilt.rows -- Follow the row the cursor was ON rather than the slot it was in: diff --git a/mod.card b/mod.card index adb5dc9..c860623 100644 --- a/mod.card +++ b/mod.card @@ -26,8 +26,8 @@ return { "WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march", "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map", "BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map", - "VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, staged battles standing on the map in both, menus on a floating panel, the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder", - "VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click toggles first/third person; in the diorama the right stick zooms, right stick click cycles the angle, and a squeezed grip drags the table's height", + "VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space, the floating panel wearing the GB frame near-square rather than the whole monitor-wide window, the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder", + "VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click toggles first/third person; in 1ST the right stick snap-turns 45 degrees a flick; in the diorama the right stick zooms and a squeezed grip drags the table's height; no controller button leaves VR -- that is the VR row's job", "a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday", "an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp", "a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode", @@ -46,7 +46,11 @@ return { "1ST needs the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge", "in 1ST, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land", "rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it", - "VR is Windows x64 only (the shipped loader and the Win32 GL binding), renders the scene once per eye (heavy with WATER FULL or AA up), has no XR controller bindings in v1 (pad/keyboard/mouse still work), and cannot start from a mounted .love archive -- the FFI needs the loader DLL on a real disk path", + "VR is Windows x64 only (the shipped loader and the Win32 GL binding): on any other platform -- mobile above all -- the VR row is absent from the OPTIONS menu and the manager's page both, and a stored vr=true carried over in a save is ignored. On Windows it renders the scene once per eye (heavy with WATER FULL or AA up) and cannot start from a mounted .love archive -- the FFI needs the loader DLL on a real disk path; pad/keyboard/mouse keep working alongside the XR controllers", + "VR on and off are both the VR row's job (options menu or manager); no controller button does either", + "the Pokedex needs a TRACKED left controller; without one there is no device in hand and the floating panel carries the UI as before", + "while the VR row is ON, 3D-BTL is held ON and BACK SPRITES held OFF (the headset's battle staging assumes both), and both rows leave the OPTIONS menu until VR goes off -- their stored values come back with them", + "while a headset is live the battle HUDs keep their classic in-frame slots instead of snapping to the window's edges, on the flat mirror too -- both VR screens crop to the GB frame, and a block at the window's edge would be cropped away with it", "VR swapchains prefer plain RGBA8; a runtime that only offers sRGB shows slightly lifted colours", }, }, diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index efdb907..c709058 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -3777,12 +3777,151 @@ local rx, ry, rz = apply(R, 1, 0, 0) T.check(near(rx, 0) and near(ry, 0) and near(rz, -1), "fromQuat: a quarter turn about +Y carries east into north") +-- ------- the battle mount: the over-the-shoulder seat, faced right +-- +-- The seat sits BATTLE_DIST along the flat battle camera's own line from +-- its aim point, and the yaw is whatever turns XR forward (north) onto +-- that line's look direction. +local seat, byaw = VRRig.battleMount({ 100, 35, 332 }, { 100, 2, 200 }) +local sdx = seat[1] - 100 +local sdy = seat[2] - 2 +local sdz = seat[3] - 200 +T.check(near(math.sqrt(sdx * sdx + sdy * sdy + sdz * sdz), + VRRig.BATTLE_DIST, 1e-3), + "the battle seat sits BATTLE_DIST from the aim point") +T.check(near(byaw, 0), + "a camera due south of its focus looks north -- XR forward, yaw 0") +local eyaw = select(2, VRRig.battleMount({ 150, 10, 200 }, { 100, 10, 200 })) +T.check(near(eyaw, math.pi / 2), + "a camera east of its focus turns the mapping a quarter toward west") + +-- an eye seated with that yaw really faces the arena: an identity head at +-- the seat comes out looking WEST, and the view agrees to the metre +local seated = VRRig.eyeCamera(pose, fov, { 150, 10, 200 }, { 0, 0, 0 }, + VRRig.FP_SCALE, math.pi / 2) +T.check(near(seated.eye[1], 150) and near(seated.eye[3], 200), + "the yawed mapping still pins the resting head to the pivot") +T.check(seated.focus[1] < seated.eye[1] - 1, + "and turns its gaze west, toward the focus it was seated against") +local wx2, wy2, wz2 = apply(seated.view, 150 - VRRig.FP_SCALE, 10, 200) +T.check(near(wx2, 0) and near(wy2, 0) and near(wz2, -1), + "the yawed view carries one metre west of the pivot to one metre ahead") + +-- ------- the hand prop: the pokedex rides the same mapping as the eyes +-- +-- propMatrix carries a hand pose through worldFromXr: an identity hand at +-- the LOCAL origin lands ON the pivot, offsets scale by px-per-metre, and +-- the battle mount's yaw turns the prop with the whole mapping. +local handPose = { pos = { 0, 0, 0 }, quat = { 0, 0, 0, 1 } } +local pm = VRRig.propMatrix(handPose, { 500, 20, 700 }, { 0, 0, 0 }, 10) +local hx, hy, hz = apply(pm, 0, 0, 0) +T.check(near(hx, 500) and near(hy, 20) and near(hz, 700), + "an identity hand at the origin puts the prop on the pivot") +local hx2, hy2, hz2 = apply(pm, 0.1, 0.2, 0) +T.check(near(hx2, 501) and near(hy2, 22) and near(hz2, 700), + "prop-local metres scale to world pixels through the mapping") +local pmYaw = VRRig.propMatrix(handPose, { 500, 20, 700 }, { 0, 0, 0 }, + 10, math.pi / 2) +local yx, yy, yz = apply(pmYaw, 0, 0, -1) +T.check(near(yx, 490) and near(yy, 20) and near(yz, 700), + "the battle mount's yaw turns the prop with the mapping: local " + .. "forward comes out west") + +-- and the pokedex module holds its shape headless: no frame until VR +-- places one, placement builds a model matrix, clear() takes it away +local Dex = run.loader.exports.DRAMATIC_SHAPE.lib.require("Pokedex") +T.eq(Dex.frame, nil, "no session, no pokedex frame") +Dex.place(handPose, { 500, 20, 700 }, { 0, 0, 0 }, 10) +T.check(Dex.frame ~= nil and type(Dex.frame.model) == "table" + and #Dex.frame.model == 16, + "placing the pokedex on a hand pose builds its world model matrix") +T.check(Dex.frame.tex == nil, + "and the screen stays dark until something is put on it") +T.check(pcall(Dex.draw), + "drawing headless is a clean no-op -- no meshes, no crash") +Dex.clear() +T.eq(Dex.frame, nil, "clear() takes the device away") +T.check(type(Dex.VOX) == "number" and Dex.VOX > 0, + "the voxel size is a named, tunable number") +T.check(near(Dex.TILT, -math.pi / 2), + "the device lies a full quarter turn forward, flush with the controller") + +-- the sprite lean override and the anchored sky's knobs exist, unset and +-- set respectively, and an unstaged world offers no battle mount +local VS_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelScene") +T.eq(VS_.spriteLean, nil, + "the sprite lean override ships unset -- the flat screen leans with the rung") +local Sky_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky") +T.check(type(Sky_.ELEV_SPAN) == "number" and Sky_.ELEV_SPAN > 0, + "the anchored sky hangs its gradient over a fixed elevation span") +local OB_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") +T.eq(OB_.stage(), nil, "no staged fight, no battle mount") +T.eq(OB_.battle(), nil, "and no battle state for the UI panel to cut up") + +-- ------- the effects plane: solved like the camera was, anchors on cells +-- +-- fxCard's whole contract is that the classic layout's two slot marks land +-- on the two arena cells, so an effect authored at a slot bursts on the +-- mon standing in for it. +local BS_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleScene") +local fxArena = { player = { 96, 240 }, enemy = { 96, 192 } } +-- eyeless first (fxCard reads Voxel3D.eye at call time, and earlier +-- sections leave one behind): straight down the arena's axis the frame +-- is the fixed plane through both cells, marks exactly on them +local V3D_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") +local hadEye = V3D_.eye +V3D_.eye = nil +local fxm = BS_.fxCard(fxArena, 10, OB_.ANCHOR) +T.check(type(fxm) == "table" and #fxm == 16, "the effects plane has a model") +local pa, ea = OB_.ANCHOR.player, OB_.ANCHOR.enemy +local fpx, fpy, fpz = apply(fxm, pa[1] / 160 - 0.5, 1 - pa[2] / 144, 0) +T.check(near(fpx, 96, 1e-3) and near(fpy, 10, 1e-3) and near(fpz, 240, 1e-3), + "the player slot's mark lands on the player's cell, at the floor") +local fex, fey, fez = apply(fxm, ea[1] / 160 - 0.5, 1 - ea[2] / 144, 0) +T.check(near(fex, 96, 1e-3) and near(fey, 10, 1e-3) and near(fez, 192, 1e-3), + "and the enemy slot's mark on the enemy's cell") + +-- and BILLBOARDED at an eye: seated east of the arena the frame turns +-- square to it, and the marks still land on the cells -- this eye's own +-- rays are what pinned them +V3D_.eye = { 200, 20, 216 } +local fxb = BS_.fxCard(fxArena, 10, OB_.ANCHOR) +T.check(near(fxb[3], 1, 1e-3) and near(fxb[11], 0, 1e-3), + "the effects frame faces the eye in the east") +local bpx, bpy, bpz = apply(fxb, pa[1] / 160 - 0.5, 1 - pa[2] / 144, 0) +T.check(near(bpx, 96, 1e-3) and near(bpy, 10, 1e-3) and near(bpz, 240, 1e-3), + "billboarded, the player mark still sits over the player's cell") +local bex, bey, bez = apply(fxb, ea[1] / 160 - 0.5, 1 - ea[2] / 144, 0) +T.check(near(bex, 96, 1e-3) and near(bey, 10, 1e-3) and near(bez, 192, 1e-3), + "and the enemy mark over the enemy's") +V3D_.eye = hadEye + +-- ------- VR owns the battle rows while it is on +local VRSet = run.loader.exports.DRAMATIC_SHAPE.lib.require("VR").setting +VRSet:sync(true) +OB_.setting:sync(false) +OB_.backSetting:sync(true) +T.eq(OB_.enabled(), true, "VR on forces staged battles whatever the row says") +T.eq(OB_.backPinned(), false, "and holds back sprites off") +VRSet:sync(false) +T.eq(OB_.enabled(), false, "VR off hands the row back to its stored value") +OB_.setting:sync(true) +T.eq(OB_.backPinned(), true, "and back sprites return at theirs") +OB_.backSetting:sync(false) + -- and the VR row exists, shaped like every other mod setting local VRMod = run.loader.exports.DRAMATIC_SHAPE.lib.require("VR") T.eq(VRMod.setting.key, "vr", "the VR row persists under its own key") T.eq(VRMod.setting:get(), false, "and ships OFF") T.eq(VRMod.status(), "off", "with the status agreeing") T.check(not VRMod.active(), "no session without a runtime, and no crash") +T.eq(type(VRMod.supported), "function", + "the platform gate exists -- off Windows the row is not offered at all") +T.eq(VRMod.supported(), true, + "and a headless run (no love.system) counts as supported, harmlessly") +T.eq(type(VRMod.leave), "function", + "VR.leave stays as the programmatic door out -- no controller button " + .. "is wired to it") end vrRigSection()