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()