mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 07:00:51 +02:00
menu fix, sky rendering, pokedex size
This commit is contained in:
+48
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user