battle effect fixes

This commit is contained in:
DramaticShape
2026-08-02 11:26:54 -04:00
parent f1063abd0f
commit 44f729e8c2
14 changed files with 1110 additions and 123 deletions
+85
View File
@@ -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
+118 -2
View File
@@ -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)
+79 -14
View File
@@ -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
+263 -56
View File
@@ -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
+26
View File
@@ -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"
+67 -5
View File
@@ -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.
+28 -9
View File
@@ -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
+98 -4
View File
@@ -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
+47 -9
View File
@@ -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