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
+100 -14
View File
@@ -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
+30 -1
View File
@@ -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 |
| 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 |
+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
+23 -6
View File
@@ -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:
+7 -3
View File
@@ -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",
},
},
+139
View File
@@ -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()