menu fix, sky rendering, pokedex size

This commit is contained in:
DramaticShape
2026-08-02 13:10:20 -04:00
parent 395f51d268
commit a8c2d8ce5b
10 changed files with 396 additions and 63 deletions
+48 -2
View File
@@ -4,6 +4,52 @@
### Added
- **The Pokédex in hand is a quarter larger.** Its voxel pitch went from
1.1 cm to 1.375 cm (the body from about 10x15 cm to about 12x19 cm),
because the screen carries every menu in first person and was
squint-small at the old size. The attachment -- flush along the left
controller -- is unchanged.
- **Left stick click steps the VOXEL ladder.** In VR the click now makes
exactly the step the "3" key (and the pad's SELECT) makes -- the same
function, handed across, so the ladder walk, the FULL step-over and
the TILT/GBC FX clearing can never drift from the key's. It used to
toggle first/third person against a remembered return rung.
- **The floating panel shows the same picture at every window size.**
The GB-frame region used to be copied into the headset's panel
pixel-for-pixel, and the panel's swapchain image has a fixed size --
so a window scaled past it (fullscreen above all) ran the frame off
the copy's edge and cut the START menu out of the panel. The region
is now blitted OUT of the window and SCALED into the swapchain image
at the frame's own aspect: identical picture, identical near-square
ratio, whatever size or shape the window takes.
- **Menus stay inside the GB frame while a headset is live.** The
engine's new zoom-aware anchoring docks the START menu to the
WINDOW's edge -- and both VR screens (the floating panel and the
Pokédex) crop the window to the near-square GB frame, so a docked
menu was cropped away with the border it hugged. While a headset is
live the mod answers the engine's own "hold the anchors" predicate
with yes, and every menu blits where it was drawn: the START menu's
classic slot, flush with the frame's right edge, which is the right
edge of everything the headset shows.
- **The sky's dither is glued to the sky.** The gradient's bands were
already read by true elevation, but the GBC checker between them kept
SCREEN-cell parity -- so a head's pitch or roll slid the world-fixed
band edges over a screen-fixed checkerboard and the whole gradient
shimmered as the pattern recomputed. The ray path is now a computed
SKYBOX: each pixel's ray lands in a cell of the sky's own angular
grid (azimuth columns, elevation rows, sized to match the diorama's
pixel grid on screen), and the band, the checker's parity and the
twilight glow -- now measured by the angle to the sun's own direction
-- are all answered from that cell's centre. The screen grid
quantises nothing, so the picture behaves exactly like a
nearest-filtered texture on a dome: its cells slide smoothly with the
world, no motion of the head recomputes the pattern, and only the
clock moves the sky.
- **VR works from an installed release.** The OpenXR loader used to be
looked for only against the working directory and the game's source --
right for the dev tree, wrong for a release install, where importing
@@ -46,8 +92,8 @@
runtime's own UI. In both modes: **left stick** moves (through the
engine's own stick path, so it grid-walks the diorama and free-walks
1ST), **A/B** are A/B, **either trigger** is START, 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
the left stick** steps the VOXEL angle ladder exactly as the "3"
key does. In the diorama: **right stick up/down** zooms the
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
+1 -1
View File
@@ -39,7 +39,7 @@ alongside.
| 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 |
| left stick click | step the VOXEL angle ladder (same as the "3" key) |
| 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 |
+4 -3
View File
@@ -37,9 +37,10 @@ local VRRig = V.require("VRRig")
local Pokedex = {}
-- one voxel, in metres: a centimetre-ish grid gives the classic chunky
-- read at a believable device size (the body below comes out about
-- 10 x 15 x 2.5 cm)
Pokedex.VOX = 0.011
-- read at a device you can read a battle off (the body below comes out
-- about 12 x 19 x 3 cm -- a quarter up from the first, believable size,
-- because the screen carries every menu and was squint-small in hand)
Pokedex.VOX = 0.011 * 1.25
-- Where the device sits relative to the GRIP pose, in metres, and how it
-- is tipped. A full quarter turn forward lays the slab exactly along the
+85 -17
View File
@@ -198,10 +198,17 @@ uniform vec3 rayDv; // base + u*du + v*dv, world axes -- so each pixel
uniform float raySpan; // radians of elevation the gradient covers
uniform vec2 invSize; // 1/w, 1/h: canvas pixels to fractions
uniform float useRay; // 0 = the flat screen's frame-linear gradient
uniform float cellAng; // one checker cell in RADIANS (ray path): the
// dither's own grid, laid on azimuth/elevation so
// the pattern is glued to the SKY -- a screen-cell
// parity flips under every head motion and the
// whole gradient shimmers
uniform float alpha;
uniform float glowAmt; // twilight warmth around the low sun; 0 = none
uniform vec2 glowPos; // the sun disc, in canvas pixels
uniform float glowInvR; // 1 / the glow's reach
uniform vec2 glowPos; // the sun disc, in canvas pixels (flat path)
uniform float glowInvR; // 1 / the glow's reach in pixels (flat path)
uniform vec3 glowDir; // the sun's world direction (ray path)
uniform float glowInvA; // 1 / the glow's reach in radians (ray path)
uniform vec3 glowColor;
// Band `i`, read from its own texel centre. The index is clamped rather than
@@ -214,37 +221,62 @@ vec3 bandAt(float i) {
}
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec2 cc0 = floor(sc / cell) * cell; // top of this cell
float tn;
float parity;
float glowD = 2.0; // past the reach
if (useRay > 0.5) {
// the cell's own ray, and its true elevation: the sky by ANGLE, so
// no motion of the head -- pitch, yaw or roll -- moves a band
vec2 cc1 = (floor(sc / cell) + 0.5) * cell;
vec3 dir = rayBase + rayDu * (cc1.x * invSize.x)
+ rayDv * (cc1.y * invSize.y);
// A SKYBOX, computed instead of stored: the pixel's own ray lands in
// a cell of the sky's angular grid (azimuth columns and elevation
// rows, cellAng square), and EVERYTHING -- the band, the checker's
// parity, the glow -- is answered from that cell's centre. The
// screen grid quantises nothing here; that is the point. A screen
// quantisation of similar pitch laid under the sky grid beats
// against it (moire), and every subpixel head motion re-snaps the
// beat -- the fizz. Sampled per pixel, the picture is exactly a
// nearest-filtered texture on a dome: its cells slide smoothly with
// the world and no motion of the head recomputes the pattern. The
// one seam, where azimuth wraps behind the camera, is a single cell
// column of a dither pattern.
vec3 dir = rayBase + rayDu * (sc.x * invSize.x)
+ rayDv * (sc.y * invSize.y);
float elev = atan(dir.y, length(dir.xz));
if (elev < 0.0) { discard; } // below the horizon
tn = 1.0 - clamp(elev / max(raySpan, 0.001), 0.0, 1.0);
float ei = floor(elev / cellAng); // elevation row
if (ei < 0.0) { discard; } // below the horizon
float ai = floor(atan(dir.x, dir.z) / cellAng); // azimuth column
float elc = (ei + 0.5) * cellAng; // the row's centre
tn = 1.0 - clamp(elc / max(raySpan, 0.001), 0.0, 1.0);
parity = mod(ai + ei, 2.0);
if (glowAmt > 0.0) {
// the glow by the angle between the CELL's centre direction and
// the sun's own, so its rings are pinned to the same sky grid
float azc = (ai + 0.5) * cellAng;
vec3 cd = vec3(cos(elc) * sin(azc), sin(elc), cos(elc) * cos(azc));
glowD = acos(clamp(dot(cd, glowDir), -1.0, 1.0)) * glowInvA;
}
} else {
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
tn = clamp((row - top) / max(edge - top, 1.0), 0.0, 1.0);
parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
if (glowAmt > 0.0) {
vec2 cc = (floor(sc / cell) + 0.5) * cell;
glowD = length(cc - glowPos) * glowInvR;
}
}
float pos = tn * 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);
if (base < count - 1.0 && (pos - base) > start) {
if (parity < 0.5) { c = bandAt(base + 1.0); }
}
// The sunset's warmth, radiating from the disc: posterised to a few rungs
// and checker-dithered between them -- the same 8-bit move as the bands,
// so the glow reads as painted light rather than as a smooth airbrush --
// and measured cell-to-cell, so its rings ride the diorama's own grid.
// measured cell-to-cell on the flat frame and angle-to-angle on the
// skybox, so its rings ride whichever grid the checker itself is on.
if (glowAmt > 0.0) {
vec2 cc = (floor(sc / cell) + 0.5) * cell;
float d = length(cc - glowPos) * glowInvR;
float g = glowAmt * pow(clamp(1.0 - d, 0.0, 1.0), 2.0);
float g = glowAmt * pow(clamp(1.0 - glowD, 0.0, 1.0), 2.0);
float lvl = floor(g * 4.0);
if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; }
c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65);
@@ -600,6 +632,26 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis, ray)
if g.setBlendMode then g.setBlendMode("alpha") end
local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
-- the skybox glow needs the sun's world DIRECTION (skyBody carries it);
-- a body without one has nothing to measure angles against, so no glow
if ray and glowAmt > 0 and not (body and body.dx) then glowAmt = 0 end
-- the world direction a canvas fraction (u, v) looks along, normalised
-- -- for sizing the angular checker and the glow's angular reach below
local function rayDirAt(u, v)
local b, du, dv = ray.base, ray.du, ray.dv
local x = b[1] + du[1] * u + dv[1] * v
local y = b[2] + du[2] * u + dv[2] * v
local z = b[3] + du[3] * u + dv[3] * v
local l = math.sqrt(x * x + y * y + z * z)
if l < 1e-9 then return 0, 0, -1 end
return x / l, y / l, z / l
end
local function rayAngle(u0, v0, u1, v1)
local ax, ay, az = rayDirAt(u0, v0)
local bx, by, bz = rayDirAt(u1, v1)
local d = ax * bx + ay * by + az * bz
return math.acos(math.max(-1, math.min(1, d)))
end
local sh = getShader()
local ramp = sh and rampFor(bands)
if not ramp then sh = nil end -- no ramp, no gradient: paint it flat
@@ -620,6 +672,11 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis, ray)
sh:send("rayDv", ray.dv)
sh:send("raySpan", Sky.ELEV_SPAN)
sh:send("invSize", { 1 / w, 1 / h })
-- the angular checker's cell: the angle one dither cell spans at
-- the frame's centre, so the sky-glued grid comes out the same
-- size on screen as the diorama's own pixel grid
sh:send("cellAng",
math.max(1e-4, rayAngle(0.5, 0, 0.5, 1) * cell / h))
end
sh:send("cell", cell)
sh:send("start", Sky.DITHER and Sky.DITHER_START or 2)
@@ -627,8 +684,19 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis, ray)
sh:send("glowAmt", glowAmt)
if glowAmt > 0 then
local gc = body.glowColor or { 248, 224, 168 }
sh:send("glowPos", { body.x, body.y })
sh:send("glowInvR", 1 / math.max(1, w * Sky.GLOW_REACH))
if ray then
-- the glow in ANGLES: its direction is the sun's own, and its
-- reach is the same fraction of the view the pixel reach was
-- of the frame, so the two paths agree on how wide it looks
local dx, dy, dz = body.dx, body.dy, body.dz
local l = math.sqrt(dx * dx + dy * dy + dz * dz)
sh:send("glowDir", { dx / l, dy / l, dz / l })
sh:send("glowInvA", 1 / math.max(
1e-3, rayAngle(0, 0.5, 1, 0.5) * Sky.GLOW_REACH))
else
sh:send("glowPos", { body.x, body.y })
sh:send("glowInvR", 1 / math.max(1, w * Sky.GLOW_REACH))
end
sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 })
end
end)
+47 -37
View File
@@ -77,11 +77,9 @@ local mirrorCanvas = nil
local status = "off"
-- the diorama's live adjustments: the right stick's zoom (a multiplier on
-- the model's size), the grab-drag's height (metres of world travel), and
-- the orbit rung the view toggle returns to from first person
-- the model's size) and the grab-drag's height (metres of world travel)
local zoom = 1
local heightOff = 0
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
@@ -396,27 +394,44 @@ local function updateQuad(worldUp, fp)
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()
-- 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.
-- it is just the mirror's picture). The frame region is blitted OUT
-- of the window and SCALED into the swapchain image -- never copied
-- pixel-for-pixel, because the swapchain's size is fixed at session
-- start and a fullscreened window outgrows it, running the frame (and
-- the START menu flush with its right edge) off the copy. Scaled, the
-- panel shows the identical picture at the identical ratio whatever
-- size the window is. Source coordinates are GL's, origin bottom-left.
local crop = nil
local copied = false
pcall(function()
local BattleScene = V.require("BattleScene")
local lx, ly, s = BattleScene.letterbox()
local wpx = 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
local wpx = math.ceil(BattleScene.GB_W * s)
local hpx = math.ceil(BattleScene.GB_H * s)
local sx = math.max(0, math.floor(lx))
local sy = math.max(0, math.floor(wh - ly - hpx))
wpx = math.min(wpx, ww - sx)
hpx = math.min(hpx, wh - sy)
if wpx < 1 or hpx < 1 then return end
-- fitted to the swapchain image at the REGION's own aspect: the
-- crop then presents exactly that rect, so the panel's shape is the
-- GB frame's at any window and any swapchain size
local fit = math.min(qw / wpx, qh / hpx)
local dw = math.max(1, math.floor(wpx * fit))
local dh = math.max(1, math.floor(hpx * fit))
if VRGL.copyFrontRegionToTexture(tex, sx, sy, wpx, hpx, dw, dh) then
copied = true
crop = { 0, 0, dw, dh }
end
end)
if not copied then
-- no letterbox to cut (or the blit refused): the old whole-window
-- copy, clamped, is still a readable panel
VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh))
end
VRXR.releaseQuad()
local base = fp and QUAD_FP or QUAD_DIORAMA
if not crop then return base end
return { pos = base.pos, width = base.width, crop = crop }
@@ -429,7 +444,8 @@ end
-- both modes left stick moves (through the engine's own stick path,
-- so it grid-walks the diorama and free-walks 1ST);
-- A/B are A/B; either trigger is START; clicking the
-- LEFT stick toggles first/third person.
-- LEFT stick steps the VOXEL angle ladder exactly as
-- the "3" key (and the pad's SELECT) does.
-- 1ST only right stick left/right SNAP-TURNS 45 degrees a flick.
-- diorama only right stick up/down zooms the model; squeezing a grip
-- and moving that hand up or down drags the whole table
@@ -438,25 +454,19 @@ end
-- 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.
function VR.toggleView()
-- The left stick click makes EXACTLY the step the "3" key makes: one
-- rung up the VOXEL angle ladder, wrapping, stepping over FULL, clearing
-- TILT and GBC FX in the save -- by calling the very function the key
-- and the pad's SELECT button already share. main.lua installs it below
-- (cycleVoxel is a local of that file); the free-roam gate is the
-- registry's own, inside it, so a click over a menu or mid-warp is a
-- no-op exactly like the key.
VR.cycleVoxel = nil -- cycleVoxel(game), set by main.lua
function VR.stepView()
pcall(function()
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
Pipelines.setLevel("voxel", lastOrbit)
else
if level > 0 and not Voxel.isFull(level) then lastOrbit = level end
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
end
if Game.save and Game.save.options then
Pipelines.syncOptions(Game.save.options)
pcall(Game.writeOptions, Game)
end
if not VR.cycleVoxel then return end
VR.cycleVoxel(require("src.core.Game"))
end)
end
@@ -502,7 +512,7 @@ local function driveControls(ctl, dt, fp)
inp:gamepadaxis(nil, "leftx", ctl.moveX or 0)
inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0))
if ctl.toggleChanged and ctl.toggle then VR.toggleView() end
if ctl.toggleChanged and ctl.toggle then VR.stepView() end
-- first person's snap turn: a flick of the right stick steps the view
-- 45 degrees, once per flick -- it re-arms only when the stick comes
+37
View File
@@ -166,6 +166,43 @@ function VRGL.copyFrontBuffer(tex, w, h)
return ok
end
-- Blit a REGION of the window's front buffer into a GL texture (an XR
-- swapchain image), SCALED to (dw, dh) at the texture's origin. This is
-- the panel's route: the whole-window copy above is pixel-for-pixel, so
-- a window larger than the swapchain image simply ran off its edges --
-- fullscreen cut the GB frame's own menu off the panel. A scaled blit
-- has no such cliff: the letterbox region lands whole at the texture's
-- own resolution whatever size the window is. Source coordinates are GL
-- window space, origin bottom-left; LINEAR, because the region rarely
-- matches the target size exactly and dropped rows read worse than a
-- soft one. Restores the read buffer and framebuffer LOVE believes in.
function VRGL.copyFrontRegionToTexture(tex, sx, sy, sw, sh, dw, dh)
if not ready then return false end
local ok = pcall(function()
if not fbo then
local out = ffi.new("unsigned int[1]")
ext.glGenFramebuffers(1, out)
fbo = out[0]
end
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, 0)
gl.glReadBuffer(GL.FRONT)
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, fbo)
ext.glFramebufferTexture2D(GL.DRAW_FRAMEBUFFER, GL.COLOR_ATTACHMENT0,
GL.TEXTURE_2D, tex, 0)
ext.glBlitFramebuffer(sx, sy, sx + sw, sy + sh, 0, 0, dw, dh,
GL.COLOR_BUFFER_BIT, GL.LINEAR)
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
-- 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,
+5
View File
@@ -721,6 +721,11 @@ function Voxel3D.skyBody(w, h)
return {
x = (x / ww * 0.5 + 0.5) * w,
y = (y / ww * 0.5 + 0.5) * h,
-- the body's WORLD direction, for the skybox path: a ray-fan caller
-- measures the twilight glow by the angle between a pixel's ray and
-- this, so the glow is pinned to the sky like the bands are (see
-- Sky.paint's glowDir)
dx = b.dx, dy = b.dy, dz = b.dz,
moon = b.moon,
glowAmt = amt,
glowColor = color,
+30
View File
@@ -524,6 +524,11 @@ local function cycleVoxel(game)
return true
end
-- The VR stick click makes this same step (VR.stepView): the function is
-- a local of this file, so the handoff is explicit rather than a
-- reimplementation drifting out of date in lib/VR.lua.
VR.cycleVoxel = cycleVoxel
do
local Game = require("src.core.Game")
local Pipelines = require("src.render.Pipelines")
@@ -913,6 +918,31 @@ do
end
end
-- ------- edge-anchored menus stay in the GB frame while a headset is live
--
-- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START
-- menu to the WINDOW's top-right edge. Both VR screens -- the floating
-- panel and the Pokedex -- crop the window to the GB frame, so a menu at
-- the window's edge is cropped away with the border it docked to. The
-- engine's own answer to "a state composes its screen, keep every element
-- inside it" is uiAnchorHold, computed per frame from this predicate; a
-- live headset is exactly that situation for the WHOLE window, so the
-- predicate answers yes for as long as one is. Held menus blit where they
-- were drawn in the 160x144 canvas -- the START menu's 9,0 x 11 slot is
-- already flush with the frame's right edge, which is the right edge of
-- what the headset sees. Off-headset frames fall through untouched.
do
local Game = require("src.core.Game")
if not Game.dramaticShapeAnchorHold then
local inner = Game.uiAnchorsHeldInStack
function Game.uiAnchorsHeldInStack(stack)
if VR.active() then return true end
return inner(stack)
end
Game.dramaticShapeAnchorHold = true
end
end
-- The overworld's own pushBattle is the choke point for a wild encounter or
-- a trainer, and it is wrapped. A battle that arrives some other way -- a
-- link battle, a script pushing a BattleState directly -- reaches this
+3 -3
View File
@@ -27,8 +27,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, 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",
"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 (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), 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 steps the VOXEL angle ladder exactly as the 3 key and SELECT do; 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",
@@ -51,7 +51,7 @@ return {
"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",
"while a headset is live the battle HUDs keep their classic in-frame slots instead of snapping to the window's edges, and the engine's edge-anchored menus (the START menu above all) are held inside the frame the same way, 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",
},
},
+136
View File
@@ -4015,6 +4015,142 @@ T.eq(type(VRMod.leave), "function",
end
vrRigSection()
-- ------- the skybox's checker and glow are the sky's own, not the screen's
--
-- The bands were already read by angle, but the DITHER between them kept
-- screen-cell parity: a world-fixed band edge sliding over a screen-fixed
-- checkerboard recomputes the pattern with every head motion -- the
-- shimmer. Now the ray path lays the checker (and the twilight glow's
-- rings) on azimuth/elevation cells, and these pin what it sends.
;(function()
local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky")
local realGraphics, realImage = love.graphics, love.image
local sent = {}
local fakeShader = {
send = function(_, name, a, b, c, d) sent[name] = { a, b, c, d } end,
}
local function fakeImage(w, h)
return {
getWidth = function() return w end,
getHeight = function() return h end,
getDimensions = function() return w, h end,
setPixel = function() end,
setFilter = function() end,
setWrap = function() end,
}
end
love.image = { newImageData = function(w, h) return fakeImage(w, h) end }
love.graphics = {
getShader = function() return nil end,
setShader = function() end,
getDepthMode = function() return "lequal", true end,
setDepthMode = function() end,
setColor = function() end,
newShader = function() return fakeShader end,
newImage = function(data) return data end,
rectangle = function() end,
}
Sky.invalidate() -- rebuild the shader and ramp through the fakes
local grad = { bands = { { 8, 8, 16 }, { 48, 64, 96 }, { 96, 128, 160 } } }
-- a level fan looking north: base + u*du + v*dv, spanning 2*atan(0.5)
-- both ways at depth 1 -- easy angles to pin the sends against
local fan = { base = { -0.5, 0.5, -1 }, du = { 1, 0, 0 },
dv = { 0, -1, 0 } }
local span = 2 * math.atan(0.5)
local body = { x = 160, y = 40, dx = 0, dy = 0.5, dz = -1,
glowAmt = 0.5, glowColor = { 248, 224, 168 } }
T.eq(Sky.paint(320, 288, grad, nil, 7, body, nil, nil, fan), true,
"the skybox paints with a ray fan and a bodied glow")
T.check(sent.cellAng and sent.cellAng[1] > 0,
"the checker gets an ANGULAR cell: the dither grid is laid on "
.. "azimuth and elevation, so no head motion reslides it")
T.check(math.abs(sent.cellAng[1] - span * 7 / 288) < 1e-6,
"sized so the sky's grid matches the diorama's pixel grid on screen")
T.check(sent.glowDir ~= nil, "the glow gets the sun's world direction")
local gd = sent.glowDir[1]
local gl = math.sqrt(gd[1] ^ 2 + gd[2] ^ 2 + gd[3] ^ 2)
T.check(math.abs(gl - 1) < 1e-6 and math.abs(gd[2] - 0.4472) < 1e-3,
"normalised, the hour's own")
T.check(math.abs(sent.glowInvA[1] - 1 / (span * Sky.GLOW_REACH)) < 1e-6,
"and an angular reach cut from the view the way the pixel reach was")
T.eq(sent.glowPos, nil,
"the screen-space glow stays the flat frame's path alone")
sent = {}
fakeShader.send = function(_, name, a, b, c, d) sent[name] = { a, b, c, d } end
local bare = { x = 160, y = 40, glowAmt = 0.5,
glowColor = { 248, 224, 168 } }
T.eq(Sky.paint(320, 288, grad, nil, 7, bare, nil, nil, fan), true,
"a body with no world direction still paints")
T.eq(sent.glowAmt[1], 0,
"but offers no glow -- there is no direction to measure angles against")
T.eq(sent.glowDir, nil, "and no direction is sent")
love.graphics, love.image = realGraphics, realImage
Sky.invalidate()
-- ------- a live headset holds every menu inside the GB frame
--
-- The engine's zoom-aware anchoring docks the START menu to the WINDOW's
-- edge; both VR screens crop the window to the GB frame, so a docked
-- menu is cropped away with the border it hugged. The wrap answers the
-- engine's own uiAnchorsHeldInStack predicate with yes while a headset
-- is live, which blits every menu where it was drawn -- the START
-- menu's slot is already flush with the frame's right edge.
local Game = require("src.core.Game")
local VRMod = run.loader.exports.DRAMATIC_SHAPE.lib.require("VR")
T.eq(Game.dramaticShapeAnchorHold, true,
"the anchor-hold wrap installed at load, once")
T.eq(Game.uiAnchorsHeldInStack({ states = {} }), false,
"with no headset the engine's own answer stands: an empty stack docks")
local innerActive = VRMod.active
VRMod.active = function() return true end
T.eq(Game.uiAnchorsHeldInStack({ states = {} }), true,
"a live headset holds anchors -- menus stay inside the GB frame, "
.. "which is all either VR screen shows")
VRMod.active = innerActive
T.eq(Game.uiAnchorsHeldInStack({ states = {} }), false,
"and hands the predicate back when the headset is gone")
T.eq(Game.uiAnchorsHeldInStack({ states = { { holdsUIAnchors = true } } }),
true, "a self-composing state still holds them on its own")
-- and the panel's route to the headset is the SCALED region blit: the
-- pixel-for-pixel copy ran the GB frame off a swapchain image smaller
-- than the window (fullscreen cut the menu), so the scaled seam must
-- exist for updateQuad to reach for first
local VRGL_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("VRGL")
T.eq(type(VRGL_.copyFrontRegionToTexture), "function",
"the letterbox reaches the panel scaled, not pixel-for-pixel")
-- ------- the stick click IS the "3" key
--
-- Left stick click makes exactly the step the key (and SELECT) makes:
-- the very same function, handed across from main.lua, so the ladder
-- walk, the FULL step-over and the TILT/GBC FX clearing can never
-- drift. Through the same fixture the key tests lend.
T.eq(VRMod.cycleVoxel ~= nil, true,
"main.lua hands its cycleVoxel to the stick click")
local hadStack2, hadOw2 = Game.stack, Game.overworld
local hadSave2, hadWrite2 = Game.save, Game.writeOptions
Game.stack, Game.overworld = keyGame.stack, keyGame.overworld
Game.save, Game.writeOptions = keyGame.save, keyGame.writeOptions
Pipelines.setLevel("voxel", 0)
VRMod.stepView()
T.eq(Pipelines.levelLabel("voxel"), "15",
"the stick click steps the VOXEL ladder exactly as 3 does")
VRMod.stepView()
T.eq(Pipelines.levelLabel("voxel"), "35", "and keeps walking it")
keyGame.save.options.tilt = 2
require("src.render.Tilt").setLevel(2)
VRMod.stepView()
T.eq(keyGame.save.options.tilt, 0,
"each click clears TILT in the save, exactly as each keypress does")
Game.stack, Game.overworld = hadStack2, hadOw2
Game.save, Game.writeOptions = hadSave2, hadWrite2
Pipelines.setLevel("voxel", 0)
end)()
Pipelines.reset()
run.release()