menu fix, sky rendering, pokedex size
DramaticShape · 2026-08-02 ·
parent 395f51d268
menu fix, sky rendering, pokedex size
Changed files
CHANGELOG.md | 50 ++++++++++++++++++++++-
README.md | 2 +-
lib/Pokedex.lua | 7 ++--
lib/Sky.lua | 102 ++++++++++++++++++++++++++++++++++++++--------
lib/VR.lua | 84 +++++++++++++++++++++-----------------
lib/VRGL.lua | 37 +++++++++++++++++
lib/Voxel3D.lua | 5 +++
main.lua | 30 ++++++++++++++
mod.card | 6 +--
tests/dramatic_shape_test.lua | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 396 insertions(+), 63 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8640112..85cffb8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 0c6fd81..f20aa3d 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/lib/Pokedex.lua b/lib/Pokedex.lua
index 07bba64..242bf70 100644
--- a/lib/Pokedex.lua
+++ b/lib/Pokedex.lua
@@ -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
diff --git a/lib/Sky.lua b/lib/Sky.lua
index 1695aa0..54d5d63 100644
--- a/lib/Sky.lua
+++ b/lib/Sky.lua
@@ -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)
diff --git a/lib/VR.lua b/lib/VR.lua
index 3a261a3..e00e149 100644
--- a/lib/VR.lua
+++ b/lib/VR.lua
@@ -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
diff --git a/lib/VRGL.lua b/lib/VRGL.lua
index bf9245e..bbfdf97 100644
--- a/lib/VRGL.lua
+++ b/lib/VRGL.lua
@@ -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,
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index c161aff..fb5ef87 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -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,
diff --git a/main.lua b/main.lua
index 2b07e0f..1fff18a 100644
--- a/main.lua
+++ b/main.lua
@@ -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
diff --git a/mod.card b/mod.card
index 4866e2d..307bdde 100644
--- a/mod.card
+++ b/mod.card
@@ -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",
},
},
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 0bf3ee2..a6fb494 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -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()